@ictechgy/context-guard 0.4.9 → 0.4.11
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.
- package/CHANGELOG.md +28 -0
- package/README.ko.md +59 -31
- package/README.md +85 -36
- package/docs/benchmark-fixtures/token-savings-12task-baseline.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task-contextguard.prompt.example.md +7 -0
- package/docs/benchmark-fixtures/token-savings-12task.evidence.example.jsonl +24 -0
- package/docs/benchmark-fixtures/token-savings-12task.tasks.example.json +182 -0
- package/docs/benchmark-fixtures/token-savings-12task.variants.example.json +10 -0
- package/docs/benchmark-workflow-examples.md +3 -0
- package/docs/benchmark-workflows/context-pack-byte-proxy.example.json +278 -137
- package/docs/benchmark-workflows/measured-token-workflow.example.json +279 -138
- package/docs/benchmark-workflows/provider-cache-telemetry.example.json +279 -138
- package/docs/distribution.md +10 -7
- package/docs/experimental-benchmark-fixtures.md +30 -6
- package/package.json +4 -6
- package/packaging/homebrew/context-guard.rb.template +1 -1
- package/plugins/context-guard/.claude-plugin/plugin.json +1 -1
- package/plugins/context-guard/README.ko.md +20 -14
- package/plugins/context-guard/README.md +26 -17
- package/plugins/context-guard/bin/context-guard +147 -25
- package/plugins/context-guard/bin/context-guard-artifact +884 -79
- package/plugins/context-guard/bin/context-guard-audit +33 -2
- package/plugins/context-guard/bin/context-guard-bench +1542 -31
- package/plugins/context-guard/bin/context-guard-cache-score +665 -0
- package/plugins/context-guard/bin/context-guard-compress +146 -1
- package/plugins/context-guard/bin/context-guard-cost +790 -6
- package/plugins/context-guard/bin/context-guard-experiments +463 -26
- package/plugins/context-guard/bin/context-guard-failed-nudge +9 -2
- package/plugins/context-guard/bin/context-guard-filter +163 -7
- package/plugins/context-guard/bin/context-guard-guard-read +3 -0
- package/plugins/context-guard/bin/context-guard-pack +892 -49
- package/plugins/context-guard/bin/context-guard-rewrite-bash +3 -0
- package/plugins/context-guard/bin/context-guard-sanitize-output +76 -12
- package/plugins/context-guard/bin/context-guard-setup +165 -31
- package/plugins/context-guard/bin/context-guard-statusline +490 -283
- package/plugins/context-guard/bin/context-guard-statusline-merged +5 -0
- package/plugins/context-guard/bin/context-guard-tool-prune +480 -53
- package/plugins/context-guard/bin/context-guard-trim-output +288 -41
- package/plugins/context-guard/brief/README.md +5 -5
- package/plugins/context-guard/lib/context_guard_commands.py +230 -0
- package/plugins/context-guard/skills/setup/SKILL.md +1 -0
- package/context-guard-kit/README.md +0 -91
- package/context-guard-kit/benchmark_runner.py +0 -2401
- package/context-guard-kit/claude_transcript_cost_audit.py +0 -2346
- package/context-guard-kit/context_compress.py +0 -695
- package/context-guard-kit/context_escrow.py +0 -935
- package/context-guard-kit/context_filter.py +0 -637
- package/context-guard-kit/context_guard_cli.py +0 -325
- package/context-guard-kit/context_guard_diet.py +0 -1711
- package/context-guard-kit/context_pack.py +0 -2713
- package/context-guard-kit/cost_guard.py +0 -2349
- package/context-guard-kit/experimental_registry.py +0 -4348
- package/context-guard-kit/failed_attempt_nudge.py +0 -567
- package/context-guard-kit/guard_large_read.py +0 -690
- package/context-guard-kit/hook_secret_patterns.py +0 -43
- package/context-guard-kit/read_symbol.py +0 -483
- package/context-guard-kit/rewrite_bash_for_token_budget.py +0 -501
- package/context-guard-kit/sanitize_output.py +0 -725
- package/context-guard-kit/settings.example.json +0 -67
- package/context-guard-kit/setup_wizard.py +0 -2515
- package/context-guard-kit/statusline.sh +0 -362
- package/context-guard-kit/statusline_merged.sh +0 -157
- package/context-guard-kit/tool_schema_pruner.py +0 -837
- package/context-guard-kit/trim_command_output.py +0 -1449
|
@@ -1,2515 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Interactive project setup for the ContextGuard plugin.
|
|
3
|
-
|
|
4
|
-
The wizard applies only project-local, opt-in settings. It can run interactively
|
|
5
|
-
in a terminal, or non-interactively with --yes/--plan for Claude Code skills and
|
|
6
|
-
CI tests.
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import argparse
|
|
11
|
-
import copy
|
|
12
|
-
import datetime as _dt
|
|
13
|
-
import json
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import shlex
|
|
17
|
-
import shutil
|
|
18
|
-
import stat
|
|
19
|
-
import subprocess
|
|
20
|
-
import sys
|
|
21
|
-
import uuid
|
|
22
|
-
from dataclasses import dataclass
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import Any
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
import fcntl
|
|
28
|
-
except ImportError: # pragma: no cover - setup already requires POSIX no-follow file ops.
|
|
29
|
-
fcntl = None
|
|
30
|
-
|
|
31
|
-
SETTINGS_REL = Path(".claude/settings.json")
|
|
32
|
-
|
|
33
|
-
RECOMMENDED_DENIES = [
|
|
34
|
-
"Read(./node_modules/**)",
|
|
35
|
-
"Read(./dist/**)",
|
|
36
|
-
"Read(./build/**)",
|
|
37
|
-
"Read(./coverage/**)",
|
|
38
|
-
"Read(./logs/**)",
|
|
39
|
-
"Read(./tmp/**)",
|
|
40
|
-
"Read(./target/**)",
|
|
41
|
-
"Read(./.next/**)",
|
|
42
|
-
"Read(./.venv/**)",
|
|
43
|
-
"Read(./vendor/**)",
|
|
44
|
-
"Read(./.context-guard/**)",
|
|
45
|
-
"Read(./.claude-token-optimizer/**)",
|
|
46
|
-
"Read(./.env)",
|
|
47
|
-
"Read(./.env.*)",
|
|
48
|
-
"Read(./.npmrc)",
|
|
49
|
-
"Read(./.pypirc)",
|
|
50
|
-
"Read(./.netrc)",
|
|
51
|
-
"Read(~/.ssh/**)",
|
|
52
|
-
"Read(~/.aws/**)",
|
|
53
|
-
"Read(~/.gnupg/**)",
|
|
54
|
-
"Read(~/.kube/**)",
|
|
55
|
-
"Read(~/.docker/**)",
|
|
56
|
-
]
|
|
57
|
-
HELPER_STATUSLINE = "context-guard-statusline-merged"
|
|
58
|
-
HELPER_REWRITE_BASH = "context-guard-rewrite-bash"
|
|
59
|
-
HELPER_GUARD_READ = "context-guard-guard-read"
|
|
60
|
-
HELPER_FAILED_NUDGE = "context-guard-failed-nudge"
|
|
61
|
-
HELPER_DIET = "context-guard-diet"
|
|
62
|
-
HELPER_EQUIVALENT_BASENAMES = {
|
|
63
|
-
"context-guard-rewrite-bash": {
|
|
64
|
-
"context-guard-rewrite-bash",
|
|
65
|
-
"claude-token-rewrite-bash",
|
|
66
|
-
"rewrite_bash_for_token_budget.py",
|
|
67
|
-
},
|
|
68
|
-
"context-guard-guard-read": {
|
|
69
|
-
"context-guard-guard-read",
|
|
70
|
-
"claude-token-guard-read",
|
|
71
|
-
"guard_large_read.py",
|
|
72
|
-
},
|
|
73
|
-
"context-guard-failed-nudge": {
|
|
74
|
-
"context-guard-failed-nudge",
|
|
75
|
-
"claude-token-failed-nudge",
|
|
76
|
-
"failed_attempt_nudge.py",
|
|
77
|
-
},
|
|
78
|
-
"context-guard-statusline-merged": {
|
|
79
|
-
"context-guard-statusline-merged",
|
|
80
|
-
"claude-token-statusline-merged",
|
|
81
|
-
"statusline_merged.sh",
|
|
82
|
-
},
|
|
83
|
-
"context-guard-statusline": {
|
|
84
|
-
"context-guard-statusline",
|
|
85
|
-
"claude-token-statusline",
|
|
86
|
-
"statusline.sh",
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
DEFAULT_MODEL = "sonnet"
|
|
90
|
-
DEFAULT_EFFORT = "medium"
|
|
91
|
-
DEFAULT_FAILED_ATTEMPT_NUDGE = True
|
|
92
|
-
DEFAULT_POST_SETUP_SCAN_TOP = 5
|
|
93
|
-
POST_SETUP_SCAN_TIMEOUT_SECONDS = 20
|
|
94
|
-
PRIVATE_DIR_MODE = stat.S_IRWXU
|
|
95
|
-
ALLOWED_FIRST_ABSOLUTE_SYMLINKS = {
|
|
96
|
-
"tmp": Path("/private/tmp"),
|
|
97
|
-
"var": Path("/private/var"),
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
@dataclass
|
|
102
|
-
class Choices:
|
|
103
|
-
denies: bool = True
|
|
104
|
-
statusline: bool = True
|
|
105
|
-
bash_hook: bool = True
|
|
106
|
-
read_guard: bool = True
|
|
107
|
-
model_defaults: bool = True
|
|
108
|
-
# 동일 Bash 명령이 두 번 연속 실패하면 /clear 권유 — recommended setup 기본 ON.
|
|
109
|
-
failed_attempt_nudge: bool = DEFAULT_FAILED_ATTEMPT_NUDGE
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
@dataclass
|
|
113
|
-
class SetupResult:
|
|
114
|
-
root: Path
|
|
115
|
-
settings_path: Path
|
|
116
|
-
scope: str
|
|
117
|
-
changed: bool
|
|
118
|
-
applied: bool
|
|
119
|
-
apply_requested: bool
|
|
120
|
-
choices: Choices
|
|
121
|
-
actions: list[str]
|
|
122
|
-
backup_path: Path | None = None
|
|
123
|
-
rollback_id: str | None = None
|
|
124
|
-
rollback_path: Path | None = None
|
|
125
|
-
warnings: list[str] | None = None
|
|
126
|
-
diet_scan: dict[str, Any] | None = None
|
|
127
|
-
# Per-agent cross-agent plan; None preserves the legacy Claude-only payload
|
|
128
|
-
# shape for callers that never engage the adapter registry.
|
|
129
|
-
adapter_plan: list[dict[str, Any]] | None = None
|
|
130
|
-
|
|
131
|
-
def as_dict(self) -> dict[str, Any]:
|
|
132
|
-
return {
|
|
133
|
-
"root": str(self.root),
|
|
134
|
-
"settings_path": str(self.settings_path),
|
|
135
|
-
"scope": self.scope,
|
|
136
|
-
"changed": self.changed,
|
|
137
|
-
"applied": self.applied,
|
|
138
|
-
"apply_requested": self.apply_requested,
|
|
139
|
-
"backup_path": str(self.backup_path) if self.backup_path else None,
|
|
140
|
-
"rollback_id": self.rollback_id,
|
|
141
|
-
"rollback_path": str(self.rollback_path) if self.rollback_path else None,
|
|
142
|
-
"warnings": self.warnings or [],
|
|
143
|
-
"choices": self.choices.__dict__,
|
|
144
|
-
"actions": self.actions,
|
|
145
|
-
"diet_scan": self.diet_scan,
|
|
146
|
-
"adapter_plan": self.adapter_plan,
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
# --- Cross-agent adapter registry & dry-run setup planner --------------------
|
|
151
|
-
#
|
|
152
|
-
# ContextGuard's helpers speak plain JSON over stdin/stdout, so the same
|
|
153
|
-
# guardrails can be wired into more than just Claude Code. This registry maps
|
|
154
|
-
# known coding agents to a *capability class* that describes HOW ContextGuard
|
|
155
|
-
# can integrate with each one, and the planner renders a per-agent setup plan.
|
|
156
|
-
#
|
|
157
|
-
# The planner stays conservative and Claude-compatible:
|
|
158
|
-
# - Only the Claude native-plugin path writes hook settings (the legacy default).
|
|
159
|
-
# - Repo-rule agents get an idempotent advisory rule block, opt-in via --with-init.
|
|
160
|
-
# - native-skill / report-only agents are never written to; they are reported.
|
|
161
|
-
# It never sends work to external providers and never promises token/cost savings.
|
|
162
|
-
|
|
163
|
-
ADAPTER_RULE_BLOCK_BEGIN = "<!-- contextguard:begin -->"
|
|
164
|
-
ADAPTER_RULE_BLOCK_END = "<!-- contextguard:end -->"
|
|
165
|
-
CODEX_SKILL_REL = ".agents/skills/context-guard/SKILL.md"
|
|
166
|
-
CODEX_SKILL_MARKER_BEGIN = "<!-- contextguard:codex-skill:begin -->"
|
|
167
|
-
CODEX_SKILL_MARKER_END = "<!-- contextguard:codex-skill:end -->"
|
|
168
|
-
BRIEF_MODE_LEVELS = ("lite", "standard", "ultra")
|
|
169
|
-
BRIEF_MODE_OFF = "off"
|
|
170
|
-
BRIEF_MODE_CHOICES = (*BRIEF_MODE_LEVELS, BRIEF_MODE_OFF)
|
|
171
|
-
BRIEF_MODE_BLOCK_END = "<!-- END context-guard:brief-mode -->"
|
|
172
|
-
BRIEF_MODE_BEGIN_RE = re.compile(
|
|
173
|
-
r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
|
|
174
|
-
)
|
|
175
|
-
BRIEF_MODE_BLOCK_RE = re.compile(
|
|
176
|
-
r"(?:\n{0,2})?"
|
|
177
|
-
r"<!-- BEGIN context-guard:brief-mode level=(?P<level>[a-z]+) version=1 -->"
|
|
178
|
-
r".*?"
|
|
179
|
-
r"<!-- END context-guard:brief-mode -->"
|
|
180
|
-
r"(?:\n{0,2})?",
|
|
181
|
-
re.DOTALL,
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
class CapabilityClass:
|
|
186
|
-
"""How ContextGuard can integrate with a given agent."""
|
|
187
|
-
|
|
188
|
-
NATIVE_PLUGIN = "native-plugin" # writes native hook settings (Claude Code)
|
|
189
|
-
NATIVE_SKILL = "native-skill" # invokable skills/commands; no auto-written hooks
|
|
190
|
-
REPO_RULE = "repo-rule" # reads a repo rule file (AGENTS.md, GEMINI.md, ...)
|
|
191
|
-
REPORT_ONLY = "report-only" # no integration surface; advisory reporting only
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
@dataclass(frozen=True)
|
|
195
|
-
class AgentAdapter:
|
|
196
|
-
"""One known coding agent and how ContextGuard wires into it."""
|
|
197
|
-
|
|
198
|
-
key: str
|
|
199
|
-
display_name: str
|
|
200
|
-
capability: str
|
|
201
|
-
summary: str
|
|
202
|
-
settings_rel: str | None = None
|
|
203
|
-
rule_file: str | None = None
|
|
204
|
-
project_skill_rel: str | None = None
|
|
205
|
-
detect: tuple[str, ...] = ()
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
AGENT_ADAPTERS: tuple[AgentAdapter, ...] = (
|
|
209
|
-
AgentAdapter(
|
|
210
|
-
key="claude",
|
|
211
|
-
display_name="Claude Code",
|
|
212
|
-
capability=CapabilityClass.NATIVE_PLUGIN,
|
|
213
|
-
summary="Installs project-local hooks, denies, and statusline in .claude/settings.json.",
|
|
214
|
-
settings_rel=str(SETTINGS_REL),
|
|
215
|
-
rule_file="CLAUDE.md",
|
|
216
|
-
detect=(".claude",),
|
|
217
|
-
),
|
|
218
|
-
AgentAdapter(
|
|
219
|
-
key="codex",
|
|
220
|
-
display_name="OpenAI Codex CLI",
|
|
221
|
-
capability=CapabilityClass.REPO_RULE,
|
|
222
|
-
summary="Reads AGENTS.md; add an advisory ContextGuard rule block with --with-init and optional project skill with --with-skill.",
|
|
223
|
-
rule_file="AGENTS.md",
|
|
224
|
-
project_skill_rel=CODEX_SKILL_REL,
|
|
225
|
-
detect=("AGENTS.md", ".codex"),
|
|
226
|
-
),
|
|
227
|
-
AgentAdapter(
|
|
228
|
-
key="gemini",
|
|
229
|
-
display_name="Gemini CLI",
|
|
230
|
-
capability=CapabilityClass.REPO_RULE,
|
|
231
|
-
summary="Reads GEMINI.md; add an advisory ContextGuard rule block with --with-init.",
|
|
232
|
-
rule_file="GEMINI.md",
|
|
233
|
-
detect=("GEMINI.md", ".gemini"),
|
|
234
|
-
),
|
|
235
|
-
AgentAdapter(
|
|
236
|
-
key="cursor",
|
|
237
|
-
display_name="Cursor",
|
|
238
|
-
capability=CapabilityClass.REPO_RULE,
|
|
239
|
-
summary="Reads project rules; add an advisory ContextGuard block with --with-init.",
|
|
240
|
-
rule_file=".cursorrules",
|
|
241
|
-
detect=(".cursor", ".cursorrules"),
|
|
242
|
-
),
|
|
243
|
-
AgentAdapter(
|
|
244
|
-
key="windsurf",
|
|
245
|
-
display_name="Windsurf",
|
|
246
|
-
capability=CapabilityClass.REPO_RULE,
|
|
247
|
-
summary="Reads project rules; add an advisory ContextGuard block with --with-init.",
|
|
248
|
-
rule_file=".windsurf/rules/contextguard.md",
|
|
249
|
-
detect=(".windsurf", ".windsurfrules"),
|
|
250
|
-
),
|
|
251
|
-
AgentAdapter(
|
|
252
|
-
key="cline",
|
|
253
|
-
display_name="Cline",
|
|
254
|
-
capability=CapabilityClass.REPO_RULE,
|
|
255
|
-
summary="Reads project rules; add an advisory ContextGuard block with --with-init.",
|
|
256
|
-
rule_file=".clinerules",
|
|
257
|
-
detect=(".clinerules", ".cline"),
|
|
258
|
-
),
|
|
259
|
-
AgentAdapter(
|
|
260
|
-
key="copilot",
|
|
261
|
-
display_name="GitHub Copilot Coding Agent",
|
|
262
|
-
capability=CapabilityClass.REPO_RULE,
|
|
263
|
-
summary="Reads repository instructions; add an advisory ContextGuard block with --with-init.",
|
|
264
|
-
rule_file=".github/copilot-instructions.md",
|
|
265
|
-
detect=(".github/copilot-instructions.md",),
|
|
266
|
-
),
|
|
267
|
-
AgentAdapter(
|
|
268
|
-
key="opencode",
|
|
269
|
-
display_name="OpenCode",
|
|
270
|
-
capability=CapabilityClass.NATIVE_SKILL,
|
|
271
|
-
summary="Expose ContextGuard helpers as OpenCode commands/rules manually; no hooks are auto-written.",
|
|
272
|
-
detect=("opencode.json", ".opencode"),
|
|
273
|
-
),
|
|
274
|
-
AgentAdapter(
|
|
275
|
-
key="forgecode",
|
|
276
|
-
display_name="ForgeCode",
|
|
277
|
-
capability=CapabilityClass.REPORT_ONLY,
|
|
278
|
-
summary="No automated setup surface yet; run ContextGuard helpers from the shell and keep evidence local.",
|
|
279
|
-
detect=(".forgecode", "forgecode.json"),
|
|
280
|
-
),
|
|
281
|
-
AgentAdapter(
|
|
282
|
-
key="generic",
|
|
283
|
-
display_name="Other / unknown agent",
|
|
284
|
-
capability=CapabilityClass.REPORT_ONLY,
|
|
285
|
-
summary="No automated setup surface; run ContextGuard helpers from the shell as needed.",
|
|
286
|
-
),
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def adapter_registry() -> dict[str, AgentAdapter]:
|
|
291
|
-
"""Return the adapter registry keyed by adapter key."""
|
|
292
|
-
return {adapter.key: adapter for adapter in AGENT_ADAPTERS}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def adapter_registry_payload() -> list[dict[str, Any]]:
|
|
296
|
-
"""JSON-friendly view of the adapter registry for --list-adapters."""
|
|
297
|
-
return [
|
|
298
|
-
{
|
|
299
|
-
"key": adapter.key,
|
|
300
|
-
"display_name": adapter.display_name,
|
|
301
|
-
"capability": adapter.capability,
|
|
302
|
-
"summary": adapter.summary,
|
|
303
|
-
"settings_rel": adapter.settings_rel,
|
|
304
|
-
"rule_file": adapter.rule_file,
|
|
305
|
-
"project_skill_rel": adapter.project_skill_rel,
|
|
306
|
-
"detect": list(adapter.detect),
|
|
307
|
-
}
|
|
308
|
-
for adapter in AGENT_ADAPTERS
|
|
309
|
-
]
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def detect_agents(root: Path) -> list[str]:
|
|
313
|
-
"""Return adapter keys whose detection markers exist under root."""
|
|
314
|
-
found: list[str] = []
|
|
315
|
-
for adapter in AGENT_ADAPTERS:
|
|
316
|
-
for rel in adapter.detect:
|
|
317
|
-
if (root / rel).exists():
|
|
318
|
-
found.append(adapter.key)
|
|
319
|
-
break
|
|
320
|
-
return found
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
def resolve_target_adapters(root: Path, only: list[str] | None) -> list[AgentAdapter]:
|
|
324
|
-
"""Pick the adapters to plan/apply.
|
|
325
|
-
|
|
326
|
-
Default keeps Claude compatibility: Claude is always targeted, plus any other
|
|
327
|
-
agent detected in the repo. ``--only`` restricts to an explicit, validated set
|
|
328
|
-
so a user can, for example, set up only Codex without touching Claude.
|
|
329
|
-
"""
|
|
330
|
-
registry = adapter_registry()
|
|
331
|
-
if only:
|
|
332
|
-
keys: list[str] = []
|
|
333
|
-
for raw in only:
|
|
334
|
-
for part in str(raw).split(","):
|
|
335
|
-
key = part.strip().lower()
|
|
336
|
-
if not key:
|
|
337
|
-
continue
|
|
338
|
-
if key not in registry:
|
|
339
|
-
known = ", ".join(sorted(registry))
|
|
340
|
-
raise SystemExit(f"Unknown adapter key: {key!r}. Known adapters: {known}.")
|
|
341
|
-
if key not in keys:
|
|
342
|
-
keys.append(key)
|
|
343
|
-
return [registry[key] for key in keys]
|
|
344
|
-
detected = set(detect_agents(root))
|
|
345
|
-
keys = ["claude"] + [
|
|
346
|
-
adapter.key
|
|
347
|
-
for adapter in AGENT_ADAPTERS
|
|
348
|
-
if adapter.key not in ("claude", "generic") and adapter.key in detected
|
|
349
|
-
]
|
|
350
|
-
return [registry[key] for key in keys]
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
def render_repo_rule_block() -> str:
|
|
354
|
-
"""Advisory rule block written into repo-rule files. No savings guarantees."""
|
|
355
|
-
return "\n".join([
|
|
356
|
-
ADAPTER_RULE_BLOCK_BEGIN,
|
|
357
|
-
"## ContextGuard (advisory)",
|
|
358
|
-
"",
|
|
359
|
-
"This repository uses ContextGuard helpers to keep agent context focused.",
|
|
360
|
-
"These guardrails are advisory and do not guarantee any token or cost savings.",
|
|
361
|
-
"",
|
|
362
|
-
"- Prefer reading symbols over whole large files.",
|
|
363
|
-
"- Store large logs as local artifacts and query only the parts you need.",
|
|
364
|
-
"- Trim or summarize noisy command output instead of pasting it whole.",
|
|
365
|
-
"- Treat reported byte reductions as proxy evidence, not proof of savings.",
|
|
366
|
-
"- Keep provider caches and semantic caches opt-in; verify cache hits before claiming savings.",
|
|
367
|
-
"",
|
|
368
|
-
"See the ContextGuard README for the helper commands.",
|
|
369
|
-
ADAPTER_RULE_BLOCK_END,
|
|
370
|
-
])
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
def render_codex_skill() -> str:
|
|
374
|
-
"""Render the optional project-local Codex skill for ContextGuard."""
|
|
375
|
-
return "\n".join([
|
|
376
|
-
"---",
|
|
377
|
-
"name: context-guard",
|
|
378
|
-
"description: Use ContextGuard helpers to keep Codex context focused with local-first setup, audit, trimming, and artifact commands.",
|
|
379
|
-
"---",
|
|
380
|
-
"",
|
|
381
|
-
CODEX_SKILL_MARKER_BEGIN,
|
|
382
|
-
"# ContextGuard for Codex",
|
|
383
|
-
"",
|
|
384
|
-
"Use this skill when a task would otherwise paste large files, long logs, or repeated setup context into Codex.",
|
|
385
|
-
"",
|
|
386
|
-
"## Progressive disclosure",
|
|
387
|
-
"- Prefer `context-guard audit . --json` or `context-guard diet scan . --json` before broad repo reads.",
|
|
388
|
-
"- Use `context-guard pack` for a small, prioritized local context pack.",
|
|
389
|
-
"- Use `context-guard artifact` for large logs, then query only the relevant slices.",
|
|
390
|
-
"- Use `context-guard trim-output` or `context-guard sanitize-output` before sharing noisy command output.",
|
|
391
|
-
"",
|
|
392
|
-
"## Setup",
|
|
393
|
-
"- Project activation: `context-guard setup --agent codex --scope project --with-init --with-skill --yes`.",
|
|
394
|
-
"- Plan first: `context-guard setup --agent codex --scope project --with-init --with-skill --plan`.",
|
|
395
|
-
"- If `context-guard` is not on PATH, install it explicitly or run via `npx @ictechgy/context-guard`.",
|
|
396
|
-
"",
|
|
397
|
-
"Do not claim fixed token or cost savings from these helpers; treat byte reductions as local proxy evidence only.",
|
|
398
|
-
CODEX_SKILL_MARKER_END,
|
|
399
|
-
"",
|
|
400
|
-
])
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def _brief_mode_source_candidates(level: str) -> list[Path]:
|
|
404
|
-
"""Return deterministic source candidates for packaged/repo brief snippets."""
|
|
405
|
-
filename = f"brief-mode.{level}.md"
|
|
406
|
-
here = Path(__file__).resolve()
|
|
407
|
-
return [
|
|
408
|
-
here.parent / "brief" / filename,
|
|
409
|
-
here.parent.parent / "brief" / filename,
|
|
410
|
-
here.parent.parent / "plugins" / "context-guard" / "brief" / filename,
|
|
411
|
-
here.parent / "plugins" / "context-guard" / "brief" / filename,
|
|
412
|
-
]
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
def _extract_brief_mode_block(level: str, text: str) -> str | None:
|
|
416
|
-
"""Extract the single marker-delimited block for ``level`` from a snippet file."""
|
|
417
|
-
matches = list(BRIEF_MODE_BLOCK_RE.finditer(text))
|
|
418
|
-
level_matches = [match for match in matches if match.group("level") == level]
|
|
419
|
-
if len(level_matches) != 1:
|
|
420
|
-
return None
|
|
421
|
-
block = level_matches[0].group(0).strip()
|
|
422
|
-
if BRIEF_MODE_BLOCK_END not in block or not BRIEF_MODE_BEGIN_RE.search(block):
|
|
423
|
-
return None
|
|
424
|
-
return block
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
def render_fallback_brief_mode_block(level: str) -> str:
|
|
428
|
-
"""Render a resilient advisory brief-mode block when packaged files are absent."""
|
|
429
|
-
descriptions = {
|
|
430
|
-
"lite": "Keep replies focused. Trim pleasantries and repeated context, but keep helpful explanations.",
|
|
431
|
-
"standard": "Lead with the result, prefer bullets, and keep only one short rationale when it matters.",
|
|
432
|
-
"ultra": "Use terse result-first bullets or tables with no preamble or self-narration.",
|
|
433
|
-
}
|
|
434
|
-
if level not in BRIEF_MODE_LEVELS:
|
|
435
|
-
raise ValueError(f"unknown brief mode level: {level}")
|
|
436
|
-
return "\n".join([
|
|
437
|
-
f"<!-- BEGIN context-guard:brief-mode level={level} version=1 -->",
|
|
438
|
-
f"## Response style: brief mode ({level}) — advisory",
|
|
439
|
-
"",
|
|
440
|
-
descriptions[level],
|
|
441
|
-
"This is best-effort guidance, not a hard rule.",
|
|
442
|
-
"",
|
|
443
|
-
"Always preserve this evidence, even when trimming wording:",
|
|
444
|
-
"",
|
|
445
|
-
"- Exact file paths, with line numbers where useful (e.g. `src/app.py:42`).",
|
|
446
|
-
"- The exact commands you ran.",
|
|
447
|
-
"- Relevant command output, error messages, stack traces, and exit codes — never hide a failure.",
|
|
448
|
-
"- Code in fenced blocks whenever code is needed for correctness.",
|
|
449
|
-
"- Verification status: what you ran and whether it passed or failed.",
|
|
450
|
-
"- The list of changed files.",
|
|
451
|
-
"- Known gaps, TODOs, and assumptions.",
|
|
452
|
-
"- Caveats and anything I should double-check.",
|
|
453
|
-
"",
|
|
454
|
-
"This guidance does not promise reduced tokens or cost; measure real results before claiming savings.",
|
|
455
|
-
BRIEF_MODE_BLOCK_END,
|
|
456
|
-
])
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
def render_brief_mode_block(level: str) -> str:
|
|
460
|
-
"""Render the marker-delimited advisory snippet for a brief-mode level."""
|
|
461
|
-
if level not in BRIEF_MODE_LEVELS:
|
|
462
|
-
raise ValueError(f"unknown brief mode level: {level}")
|
|
463
|
-
for candidate in _brief_mode_source_candidates(level):
|
|
464
|
-
try:
|
|
465
|
-
text = candidate.read_text(encoding="utf-8")
|
|
466
|
-
except OSError:
|
|
467
|
-
continue
|
|
468
|
-
block = _extract_brief_mode_block(level, text)
|
|
469
|
-
if block:
|
|
470
|
-
return block
|
|
471
|
-
return render_fallback_brief_mode_block(level)
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
def _brief_mode_levels_in_text(text: str) -> list[str]:
|
|
475
|
-
return [match.group("level") for match in BRIEF_MODE_BLOCK_RE.finditer(text)]
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
def _remove_brief_mode_blocks(text: str) -> tuple[str, list[str]]:
|
|
479
|
-
"""Remove all ContextGuard-managed brief-mode blocks while preserving user text."""
|
|
480
|
-
levels = _brief_mode_levels_in_text(text)
|
|
481
|
-
stripped = BRIEF_MODE_BLOCK_RE.sub("\n\n", text)
|
|
482
|
-
stripped = re.sub(r"\n{3,}", "\n\n", stripped).strip("\n")
|
|
483
|
-
return ((stripped + "\n") if stripped else "", levels)
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def _append_managed_block(existing: str, block: str) -> str:
|
|
487
|
-
if existing.strip():
|
|
488
|
-
return existing.rstrip("\n") + "\n\n" + block + "\n"
|
|
489
|
-
return block + "\n"
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def compose_rule_file_text(
|
|
493
|
-
existing: str | None,
|
|
494
|
-
*,
|
|
495
|
-
with_init: bool,
|
|
496
|
-
brief_mode: str | None,
|
|
497
|
-
) -> tuple[str, dict[str, Any]]:
|
|
498
|
-
"""Compose final repo rule text for combined init and brief-mode mutations."""
|
|
499
|
-
text = existing or ""
|
|
500
|
-
original_text = text
|
|
501
|
-
existing_brief_levels = _brief_mode_levels_in_text(text)
|
|
502
|
-
meta: dict[str, Any] = {
|
|
503
|
-
"init_changed": False,
|
|
504
|
-
"init_present_before": ADAPTER_RULE_BLOCK_BEGIN in text,
|
|
505
|
-
"brief_levels_before": existing_brief_levels,
|
|
506
|
-
"brief_changed": False,
|
|
507
|
-
}
|
|
508
|
-
if with_init and ADAPTER_RULE_BLOCK_BEGIN not in text:
|
|
509
|
-
text = _append_managed_block(text, render_repo_rule_block())
|
|
510
|
-
meta["init_changed"] = True
|
|
511
|
-
if brief_mode:
|
|
512
|
-
stripped, removed_levels = _remove_brief_mode_blocks(text)
|
|
513
|
-
if brief_mode == BRIEF_MODE_OFF:
|
|
514
|
-
text = stripped
|
|
515
|
-
meta["brief_changed"] = bool(removed_levels)
|
|
516
|
-
else:
|
|
517
|
-
block = render_brief_mode_block(brief_mode)
|
|
518
|
-
text = _append_managed_block(stripped, block)
|
|
519
|
-
meta["brief_changed"] = removed_levels != [brief_mode] or text != original_text
|
|
520
|
-
meta["brief_levels_removed"] = removed_levels
|
|
521
|
-
meta["changed"] = text != original_text
|
|
522
|
-
return text, meta
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
def plan_or_write_rule_file_blocks(
|
|
526
|
-
path: Path,
|
|
527
|
-
*,
|
|
528
|
-
with_init: bool,
|
|
529
|
-
brief_mode: str | None,
|
|
530
|
-
applied: bool,
|
|
531
|
-
) -> dict[str, Any]:
|
|
532
|
-
"""Plan or apply managed rule-file blocks with one original backup per changed existing write."""
|
|
533
|
-
result: dict[str, Any] = {
|
|
534
|
-
"status": None,
|
|
535
|
-
"planned_actions": [],
|
|
536
|
-
"applied_actions": [],
|
|
537
|
-
"brief_mode_status": None,
|
|
538
|
-
"brief_mode_existing_levels": [],
|
|
539
|
-
"brief_mode_backup_path": None,
|
|
540
|
-
"reason": None,
|
|
541
|
-
}
|
|
542
|
-
state = _rule_file_state(path)
|
|
543
|
-
if state["status"] not in {"missing", "file"}:
|
|
544
|
-
reason = state.get("reason") or f"refused unsafe rule target: {path.name}"
|
|
545
|
-
result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
|
|
546
|
-
result["planned_actions"].append(reason)
|
|
547
|
-
return result
|
|
548
|
-
|
|
549
|
-
existing = state.get("text")
|
|
550
|
-
existing_text = str(existing or "")
|
|
551
|
-
result["brief_mode_existing_levels"] = _brief_mode_levels_in_text(existing_text)
|
|
552
|
-
rule_present = existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing_text
|
|
553
|
-
planned_meta: dict[str, Any] | None = None
|
|
554
|
-
if brief_mode:
|
|
555
|
-
_, planned_meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
|
|
556
|
-
|
|
557
|
-
if with_init:
|
|
558
|
-
if rule_present:
|
|
559
|
-
result["status"] = "exists"
|
|
560
|
-
result["planned_actions"].append("advisory ContextGuard rules already present")
|
|
561
|
-
elif not applied:
|
|
562
|
-
result["status"] = "planned"
|
|
563
|
-
result["planned_actions"].append("would add advisory ContextGuard rules")
|
|
564
|
-
elif not brief_mode:
|
|
565
|
-
result["status"] = "planned"
|
|
566
|
-
result["planned_actions"].append("run with --with-init to add advisory ContextGuard rules")
|
|
567
|
-
|
|
568
|
-
if brief_mode:
|
|
569
|
-
brief_changed = bool(planned_meta and planned_meta.get("brief_changed"))
|
|
570
|
-
if brief_mode == BRIEF_MODE_OFF:
|
|
571
|
-
if brief_changed:
|
|
572
|
-
result["brief_mode_status"] = "planned" if not applied else None
|
|
573
|
-
if not applied:
|
|
574
|
-
result["planned_actions"].append("would remove advisory brief-mode rules")
|
|
575
|
-
else:
|
|
576
|
-
result["brief_mode_status"] = "absent"
|
|
577
|
-
result["planned_actions"].append("advisory brief-mode rules already absent")
|
|
578
|
-
else:
|
|
579
|
-
levels = result["brief_mode_existing_levels"]
|
|
580
|
-
if not brief_changed:
|
|
581
|
-
result["brief_mode_status"] = "exists"
|
|
582
|
-
result["planned_actions"].append(f"advisory brief-mode {brief_mode} rules already present")
|
|
583
|
-
elif not applied:
|
|
584
|
-
result["brief_mode_status"] = "planned"
|
|
585
|
-
action = "refresh" if levels == [brief_mode] else ("replace" if levels else "add")
|
|
586
|
-
result["planned_actions"].append(f"would {action} advisory brief-mode {brief_mode} rules")
|
|
587
|
-
|
|
588
|
-
if not applied:
|
|
589
|
-
if result["status"] is None:
|
|
590
|
-
result["status"] = "planned" if result["planned_actions"] else "unchanged"
|
|
591
|
-
return result
|
|
592
|
-
|
|
593
|
-
final_text, meta = compose_rule_file_text(existing, with_init=with_init, brief_mode=brief_mode)
|
|
594
|
-
if not meta["changed"]:
|
|
595
|
-
if result["status"] is None:
|
|
596
|
-
result["status"] = "exists" if rule_present else "unchanged"
|
|
597
|
-
if result["brief_mode_status"] is None and brief_mode:
|
|
598
|
-
result["brief_mode_status"] = "absent" if brief_mode == BRIEF_MODE_OFF else "exists"
|
|
599
|
-
return result
|
|
600
|
-
|
|
601
|
-
backup_path = None
|
|
602
|
-
if existing is not None:
|
|
603
|
-
try:
|
|
604
|
-
backup_path = backup_existing(path)
|
|
605
|
-
except OSError as exc:
|
|
606
|
-
reason = f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"
|
|
607
|
-
result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
|
|
608
|
-
result["planned_actions"] = [reason]
|
|
609
|
-
return result
|
|
610
|
-
durability_warning = None
|
|
611
|
-
try:
|
|
612
|
-
atomic_write(
|
|
613
|
-
path,
|
|
614
|
-
final_text,
|
|
615
|
-
existing_mode_or_default(path, 0o644) if existing is not None else 0o644,
|
|
616
|
-
dir_mode=0o755,
|
|
617
|
-
)
|
|
618
|
-
except AtomicWriteDurabilityError as exc:
|
|
619
|
-
durability_warning = str(exc)
|
|
620
|
-
except OSError as exc:
|
|
621
|
-
reason = f"could not write repo rule file {path.name}: {exc.__class__.__name__}"
|
|
622
|
-
result.update({"status": "skipped", "brief_mode_status": "skipped", "reason": reason})
|
|
623
|
-
result["planned_actions"] = [reason]
|
|
624
|
-
return result
|
|
625
|
-
|
|
626
|
-
if backup_path:
|
|
627
|
-
result["brief_mode_backup_path"] = str(backup_path)
|
|
628
|
-
if durability_warning:
|
|
629
|
-
result["status"] = "applied-durability-uncertain"
|
|
630
|
-
result["reason"] = durability_warning
|
|
631
|
-
if with_init:
|
|
632
|
-
if not durability_warning:
|
|
633
|
-
result["status"] = "applied" if meta["init_changed"] else "exists"
|
|
634
|
-
if meta["init_changed"]:
|
|
635
|
-
result["applied_actions"].append("wrote advisory ContextGuard rules")
|
|
636
|
-
else:
|
|
637
|
-
result["planned_actions"].append("advisory ContextGuard rules already present")
|
|
638
|
-
elif result["status"] is None:
|
|
639
|
-
result["status"] = "unchanged"
|
|
640
|
-
if brief_mode:
|
|
641
|
-
if brief_mode == BRIEF_MODE_OFF:
|
|
642
|
-
result["brief_mode_status"] = "removed" if meta["brief_changed"] else "absent"
|
|
643
|
-
if meta["brief_changed"]:
|
|
644
|
-
result["applied_actions"].append("removed advisory brief-mode rules")
|
|
645
|
-
else:
|
|
646
|
-
result["planned_actions"].append("advisory brief-mode rules already absent")
|
|
647
|
-
else:
|
|
648
|
-
before = meta.get("brief_levels_removed") or []
|
|
649
|
-
if before and before != [brief_mode]:
|
|
650
|
-
result["brief_mode_status"] = "replaced"
|
|
651
|
-
elif before == [brief_mode]:
|
|
652
|
-
result["brief_mode_status"] = "updated"
|
|
653
|
-
else:
|
|
654
|
-
result["brief_mode_status"] = "applied"
|
|
655
|
-
result["applied_actions"].append(f"wrote advisory brief-mode {brief_mode} rules")
|
|
656
|
-
if durability_warning:
|
|
657
|
-
result["planned_actions"].append(durability_warning)
|
|
658
|
-
result["planned_actions"].extend(result["applied_actions"])
|
|
659
|
-
return result
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
def _read_rule_file_text(path: Path) -> str | None:
|
|
663
|
-
"""Best-effort no-follow read; only a missing file is treated as absent.
|
|
664
|
-
|
|
665
|
-
Unreadable, symlinked, directory, or otherwise unsafe targets must not be
|
|
666
|
-
collapsed into "missing"; doing so could overwrite user-owned instruction
|
|
667
|
-
files. Callers that want a non-throwing view should use
|
|
668
|
-
``_rule_file_state`` and skip unsafe targets explicitly.
|
|
669
|
-
"""
|
|
670
|
-
try:
|
|
671
|
-
return _read_text_no_follow(path)
|
|
672
|
-
except FileNotFoundError:
|
|
673
|
-
return None
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
def _existing_rule_parent_issue(path: Path) -> str | None:
|
|
677
|
-
"""Return a reason when an existing parent component is unsafe to traverse.
|
|
678
|
-
|
|
679
|
-
Missing parent directories are intentionally allowed: atomic writes create them
|
|
680
|
-
with explicit modes. Existing symlink/non-directory parents are not allowed,
|
|
681
|
-
because plan/apply must agree and must never follow an attacker-swapped rule
|
|
682
|
-
directory outside the project.
|
|
683
|
-
"""
|
|
684
|
-
parts = path.parts[1:-1] if path.is_absolute() else path.parts[:-1]
|
|
685
|
-
if not parts:
|
|
686
|
-
return None
|
|
687
|
-
current = Path(path.anchor) if path.is_absolute() else Path()
|
|
688
|
-
for part in parts:
|
|
689
|
-
current = current / part
|
|
690
|
-
try:
|
|
691
|
-
st = os.lstat(current)
|
|
692
|
-
except FileNotFoundError:
|
|
693
|
-
return None
|
|
694
|
-
except OSError as exc:
|
|
695
|
-
return f"could not inspect rule parent {current}: {exc.__class__.__name__}"
|
|
696
|
-
if stat.S_ISLNK(st.st_mode):
|
|
697
|
-
return f"refused to traverse symlinked rule parent: {current}"
|
|
698
|
-
if not stat.S_ISDIR(st.st_mode):
|
|
699
|
-
return f"refused non-directory rule parent: {current}"
|
|
700
|
-
return None
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
def _rule_file_state(path: Path) -> dict[str, Any]:
|
|
704
|
-
"""Return a non-throwing state for project rule/skill files."""
|
|
705
|
-
parent_issue = _existing_rule_parent_issue(path)
|
|
706
|
-
if parent_issue:
|
|
707
|
-
return {"status": "unsafe", "text": None, "reason": parent_issue}
|
|
708
|
-
try:
|
|
709
|
-
st = os.lstat(path)
|
|
710
|
-
except FileNotFoundError:
|
|
711
|
-
return {"status": "missing", "text": None, "reason": None}
|
|
712
|
-
except OSError as exc:
|
|
713
|
-
return {"status": "unsafe", "text": None, "reason": f"could not inspect rule file: {exc.__class__.__name__}"}
|
|
714
|
-
if stat.S_ISLNK(st.st_mode):
|
|
715
|
-
return {"status": "unsafe", "text": None, "reason": f"refused to read symlinked rule file: {path.name}"}
|
|
716
|
-
if stat.S_ISDIR(st.st_mode):
|
|
717
|
-
return {"status": "directory", "text": None, "reason": f"refused to replace directory rule target: {path.name}"}
|
|
718
|
-
try:
|
|
719
|
-
text = _read_text_no_follow(path)
|
|
720
|
-
except OSError as exc:
|
|
721
|
-
return {
|
|
722
|
-
"status": "unsafe",
|
|
723
|
-
"text": None,
|
|
724
|
-
"reason": f"could not read rule file without following symlinks: {exc.__class__.__name__}",
|
|
725
|
-
}
|
|
726
|
-
return {"status": "file", "text": text, "reason": None}
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
def repo_rule_block_present(path: Path) -> bool:
|
|
730
|
-
"""True when the advisory ContextGuard block already exists in the rule file."""
|
|
731
|
-
state = _rule_file_state(path)
|
|
732
|
-
return state["status"] == "file" and ADAPTER_RULE_BLOCK_BEGIN in str(state.get("text") or "")
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
def write_repo_rule_init(path: Path) -> dict[str, Any]:
|
|
736
|
-
"""Idempotently append the advisory ContextGuard block to a repo rule file.
|
|
737
|
-
|
|
738
|
-
Returns a status dict: ``applied`` (block written), ``exists`` (already
|
|
739
|
-
present), or ``skipped`` (refused, e.g. symlinked target) with a reason.
|
|
740
|
-
Existing user-owned rule files are backed up before any changed write.
|
|
741
|
-
"""
|
|
742
|
-
state = _rule_file_state(path)
|
|
743
|
-
if state["status"] not in {"missing", "file"}:
|
|
744
|
-
return {"status": "skipped", "reason": state.get("reason") or f"refused unsafe rule target: {path.name}"}
|
|
745
|
-
existing = state.get("text")
|
|
746
|
-
if existing is not None and ADAPTER_RULE_BLOCK_BEGIN in existing:
|
|
747
|
-
return {"status": "exists"}
|
|
748
|
-
block = render_repo_rule_block()
|
|
749
|
-
if existing:
|
|
750
|
-
new_text = existing.rstrip("\n") + "\n\n" + block + "\n"
|
|
751
|
-
else:
|
|
752
|
-
new_text = block + "\n"
|
|
753
|
-
mode = existing_mode_or_default(path, 0o644) if existing is not None else 0o644
|
|
754
|
-
backup_path = None
|
|
755
|
-
if existing is not None:
|
|
756
|
-
try:
|
|
757
|
-
backup_path = backup_existing(path)
|
|
758
|
-
except OSError as exc:
|
|
759
|
-
return {"status": "skipped", "reason": f"could not back up repo rule file {path.name}: {exc.__class__.__name__}"}
|
|
760
|
-
durability_warning = None
|
|
761
|
-
try:
|
|
762
|
-
atomic_write(path, new_text, mode, dir_mode=0o755)
|
|
763
|
-
except AtomicWriteDurabilityError as exc:
|
|
764
|
-
durability_warning = str(exc)
|
|
765
|
-
except OSError as exc:
|
|
766
|
-
result = {"status": "skipped", "reason": f"could not write repo rule file {path.name}: {exc.__class__.__name__}"}
|
|
767
|
-
if backup_path:
|
|
768
|
-
result["backup_path"] = str(backup_path)
|
|
769
|
-
return result
|
|
770
|
-
result = {"status": "applied", "backup_path": str(backup_path) if backup_path else None}
|
|
771
|
-
if durability_warning:
|
|
772
|
-
result["status"] = "applied-durability-uncertain"
|
|
773
|
-
result["reason"] = durability_warning
|
|
774
|
-
return result
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
def codex_skill_status(path: Path) -> str:
|
|
778
|
-
state = _rule_file_state(path)
|
|
779
|
-
if state["status"] == "missing":
|
|
780
|
-
return "missing"
|
|
781
|
-
if state["status"] != "file":
|
|
782
|
-
return "unsafe"
|
|
783
|
-
text = str(state.get("text") or "")
|
|
784
|
-
if text == render_codex_skill():
|
|
785
|
-
return "exists"
|
|
786
|
-
if CODEX_SKILL_MARKER_BEGIN in text and CODEX_SKILL_MARKER_END in text:
|
|
787
|
-
return "update-needed"
|
|
788
|
-
return "foreign"
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
def write_codex_project_skill(path: Path) -> dict[str, Any]:
|
|
792
|
-
"""Idempotently create/update the project-local Codex ContextGuard skill."""
|
|
793
|
-
state = _rule_file_state(path)
|
|
794
|
-
if state["status"] not in {"missing", "file"}:
|
|
795
|
-
return {"status": "skipped", "reason": state.get("reason") or f"refused unsafe skill target: {path.name}"}
|
|
796
|
-
status = codex_skill_status(path)
|
|
797
|
-
if status == "exists":
|
|
798
|
-
return {"status": "exists"}
|
|
799
|
-
if status == "foreign":
|
|
800
|
-
return {
|
|
801
|
-
"status": "skipped",
|
|
802
|
-
"reason": f"refused to overwrite non-ContextGuard Codex skill file: {path}",
|
|
803
|
-
}
|
|
804
|
-
try:
|
|
805
|
-
atomic_write(path, render_codex_skill(), 0o644, dir_mode=0o755)
|
|
806
|
-
except OSError as exc:
|
|
807
|
-
return {"status": "skipped", "reason": f"could not write Codex skill file {path}: {exc.__class__.__name__}"}
|
|
808
|
-
return {"status": "updated" if status == "update-needed" else "applied"}
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
def adapter_rule_path(root: Path, adapter: AgentAdapter) -> Path | None:
|
|
812
|
-
"""Resolve a repo-rule adapter's write target.
|
|
813
|
-
|
|
814
|
-
Most adapters have a stable file target. Cline is deliberately flexible:
|
|
815
|
-
existing projects commonly use `.clinerules` as a file, while some may use a
|
|
816
|
-
directory-style rules surface. Pick a file when `.clinerules` is absent or a
|
|
817
|
-
file; use a nested advisory file only when `.clinerules` already exists as a
|
|
818
|
-
real directory. This avoids crashing or replacing a user-owned file-form rule.
|
|
819
|
-
"""
|
|
820
|
-
if adapter.rule_file is None:
|
|
821
|
-
return None
|
|
822
|
-
if adapter.key == "cline":
|
|
823
|
-
base = root / ".clinerules"
|
|
824
|
-
if base.exists() and base.is_dir() and not base.is_symlink():
|
|
825
|
-
return base / "contextguard.md"
|
|
826
|
-
return base
|
|
827
|
-
return root / adapter.rule_file
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def build_adapter_plan(
|
|
831
|
-
root: Path,
|
|
832
|
-
targets: list[AgentAdapter],
|
|
833
|
-
*,
|
|
834
|
-
scope: str,
|
|
835
|
-
claude_actions: list[str],
|
|
836
|
-
claude_changed: bool,
|
|
837
|
-
claude_applied: bool,
|
|
838
|
-
with_init: bool,
|
|
839
|
-
with_skill: bool,
|
|
840
|
-
applied: bool,
|
|
841
|
-
brief_mode: str | None = None,
|
|
842
|
-
) -> list[dict[str, Any]]:
|
|
843
|
-
"""Render a per-adapter plan, performing safe repo-rule writes when applied.
|
|
844
|
-
|
|
845
|
-
Repo-rule adapters write when ``applied`` is set and either ``with_init`` or
|
|
846
|
-
project-scope ``brief_mode`` requested a managed rule-file block. Native-plugin
|
|
847
|
-
entries mirror the Claude settings result; native-skill and report-only entries
|
|
848
|
-
are advisory and never write.
|
|
849
|
-
"""
|
|
850
|
-
detected = set(detect_agents(root))
|
|
851
|
-
plan: list[dict[str, Any]] = []
|
|
852
|
-
for adapter in targets:
|
|
853
|
-
entry: dict[str, Any] = {
|
|
854
|
-
"key": adapter.key,
|
|
855
|
-
"display_name": adapter.display_name,
|
|
856
|
-
"capability": adapter.capability,
|
|
857
|
-
"scope": scope,
|
|
858
|
-
"detected": adapter.key in detected,
|
|
859
|
-
"summary": adapter.summary,
|
|
860
|
-
"writable": False,
|
|
861
|
-
"status": "report-only",
|
|
862
|
-
"planned_actions": [],
|
|
863
|
-
"applied_actions": [],
|
|
864
|
-
"unsupported_reason": None,
|
|
865
|
-
}
|
|
866
|
-
if brief_mode:
|
|
867
|
-
entry["brief_mode"] = brief_mode
|
|
868
|
-
entry["brief_mode_status"] = "unsupported"
|
|
869
|
-
entry["brief_mode_level"] = None if brief_mode == BRIEF_MODE_OFF else brief_mode
|
|
870
|
-
entry["brief_mode_file"] = None
|
|
871
|
-
entry["brief_mode_existing_levels"] = []
|
|
872
|
-
entry["brief_mode_backup_path"] = None
|
|
873
|
-
entry["brief_mode_reason"] = None
|
|
874
|
-
if scope == "user" and adapter.key != "claude":
|
|
875
|
-
entry["status"] = "unsupported"
|
|
876
|
-
entry["writable"] = False
|
|
877
|
-
entry["unsupported_reason"] = (
|
|
878
|
-
f"user-scope activation for {adapter.display_name} is not implemented/verified yet; "
|
|
879
|
-
"use --scope project or run the helper commands manually."
|
|
880
|
-
)
|
|
881
|
-
entry["planned_actions"] = [entry["unsupported_reason"]]
|
|
882
|
-
if brief_mode:
|
|
883
|
-
entry["brief_mode_reason"] = entry["unsupported_reason"]
|
|
884
|
-
plan.append(entry)
|
|
885
|
-
continue
|
|
886
|
-
if adapter.capability == CapabilityClass.NATIVE_PLUGIN:
|
|
887
|
-
entry["writable"] = True
|
|
888
|
-
if adapter.settings_rel:
|
|
889
|
-
entry["settings_path"] = str(root / adapter.settings_rel)
|
|
890
|
-
entry["planned_actions"] = list(claude_actions)
|
|
891
|
-
if claude_applied and claude_changed:
|
|
892
|
-
entry["status"] = "applied"
|
|
893
|
-
elif claude_changed:
|
|
894
|
-
entry["status"] = "planned"
|
|
895
|
-
else:
|
|
896
|
-
entry["status"] = "unchanged"
|
|
897
|
-
if brief_mode:
|
|
898
|
-
rule_path = adapter_rule_path(root, adapter)
|
|
899
|
-
entry["rule_file"] = str(rule_path.relative_to(root)) if rule_path and scope == "project" else adapter.rule_file
|
|
900
|
-
entry["brief_mode_file"] = entry.get("rule_file")
|
|
901
|
-
if scope != "project" or rule_path is None:
|
|
902
|
-
entry["brief_mode_status"] = "unsupported"
|
|
903
|
-
entry["brief_mode_reason"] = "brief-mode rule-file writes are project-scope only"
|
|
904
|
-
entry["planned_actions"].append(entry["brief_mode_reason"])
|
|
905
|
-
else:
|
|
906
|
-
result = plan_or_write_rule_file_blocks(
|
|
907
|
-
rule_path,
|
|
908
|
-
with_init=False,
|
|
909
|
-
brief_mode=brief_mode,
|
|
910
|
-
applied=applied,
|
|
911
|
-
)
|
|
912
|
-
entry["brief_mode_status"] = result["brief_mode_status"]
|
|
913
|
-
entry["brief_mode_existing_levels"] = result["brief_mode_existing_levels"]
|
|
914
|
-
entry["brief_mode_backup_path"] = result["brief_mode_backup_path"]
|
|
915
|
-
entry["brief_mode_reason"] = result.get("reason")
|
|
916
|
-
for action in result.get("planned_actions", []):
|
|
917
|
-
entry["planned_actions"].append(f"{action} in {entry['rule_file']}")
|
|
918
|
-
for action in result.get("applied_actions", []):
|
|
919
|
-
entry["applied_actions"].append(f"{action} in {entry['rule_file']}")
|
|
920
|
-
if result.get("applied_actions"):
|
|
921
|
-
entry["status"] = (
|
|
922
|
-
result["status"]
|
|
923
|
-
if result.get("status") == "applied-durability-uncertain"
|
|
924
|
-
else "applied"
|
|
925
|
-
)
|
|
926
|
-
if result.get("reason"):
|
|
927
|
-
entry["brief_mode_reason"] = result.get("reason")
|
|
928
|
-
elif adapter.capability == CapabilityClass.REPO_RULE:
|
|
929
|
-
entry["writable"] = True
|
|
930
|
-
rule_path = adapter_rule_path(root, adapter)
|
|
931
|
-
entry["rule_file"] = str(rule_path.relative_to(root)) if rule_path else adapter.rule_file
|
|
932
|
-
if brief_mode and scope != "project":
|
|
933
|
-
entry["brief_mode_status"] = "unsupported"
|
|
934
|
-
entry["brief_mode_reason"] = "brief-mode rule-file writes are project-scope only"
|
|
935
|
-
entry["planned_actions"].append(entry["brief_mode_reason"])
|
|
936
|
-
elif brief_mode and rule_path is not None:
|
|
937
|
-
entry["brief_mode_file"] = entry["rule_file"]
|
|
938
|
-
result = plan_or_write_rule_file_blocks(
|
|
939
|
-
rule_path,
|
|
940
|
-
with_init=with_init,
|
|
941
|
-
brief_mode=brief_mode,
|
|
942
|
-
applied=applied,
|
|
943
|
-
)
|
|
944
|
-
entry["status"] = result["status"]
|
|
945
|
-
entry["brief_mode_status"] = result["brief_mode_status"]
|
|
946
|
-
entry["brief_mode_existing_levels"] = result["brief_mode_existing_levels"]
|
|
947
|
-
entry["brief_mode_backup_path"] = result["brief_mode_backup_path"]
|
|
948
|
-
entry["brief_mode_reason"] = result.get("reason")
|
|
949
|
-
entry["planned_actions"] = [f"{action} in {entry['rule_file']}" for action in result.get("planned_actions", [])]
|
|
950
|
-
entry["applied_actions"] = [f"{action} in {entry['rule_file']}" for action in result.get("applied_actions", [])]
|
|
951
|
-
if result.get("applied_actions"):
|
|
952
|
-
entry["status"] = (
|
|
953
|
-
result["status"]
|
|
954
|
-
if result.get("status") == "applied-durability-uncertain"
|
|
955
|
-
else "applied"
|
|
956
|
-
)
|
|
957
|
-
else:
|
|
958
|
-
if rule_path is not None and repo_rule_block_present(rule_path):
|
|
959
|
-
entry["status"] = "exists"
|
|
960
|
-
entry["planned_actions"] = [f"advisory ContextGuard rules already present in {entry['rule_file']}"]
|
|
961
|
-
elif not with_init:
|
|
962
|
-
entry["status"] = "planned"
|
|
963
|
-
entry["planned_actions"] = [f"run with --with-init to add advisory ContextGuard rules to {entry['rule_file']}"]
|
|
964
|
-
elif not applied:
|
|
965
|
-
entry["status"] = "planned"
|
|
966
|
-
entry["planned_actions"] = [f"would add advisory ContextGuard rules to {entry['rule_file']}"]
|
|
967
|
-
elif rule_path is not None:
|
|
968
|
-
result = write_repo_rule_init(rule_path)
|
|
969
|
-
entry["status"] = result["status"]
|
|
970
|
-
if result["status"] in {"applied", "applied-durability-uncertain"}:
|
|
971
|
-
entry["applied_actions"] = [f"wrote advisory ContextGuard rules to {entry['rule_file']}"]
|
|
972
|
-
entry["planned_actions"] = list(entry["applied_actions"])
|
|
973
|
-
if result.get("reason"):
|
|
974
|
-
entry["planned_actions"].append(result["reason"])
|
|
975
|
-
entry["reason"] = result["reason"]
|
|
976
|
-
if result.get("backup_path"):
|
|
977
|
-
entry["rule_backup_path"] = result["backup_path"]
|
|
978
|
-
elif result["status"] == "exists":
|
|
979
|
-
entry["planned_actions"] = [f"advisory ContextGuard rules already present in {entry['rule_file']}"]
|
|
980
|
-
else:
|
|
981
|
-
entry["planned_actions"] = [result.get("reason", "skipped")]
|
|
982
|
-
if adapter.key == "codex" and adapter.project_skill_rel:
|
|
983
|
-
skill_path = root / adapter.project_skill_rel
|
|
984
|
-
entry["project_skill_file"] = adapter.project_skill_rel
|
|
985
|
-
skill_state = codex_skill_status(skill_path)
|
|
986
|
-
entry["project_skill_status"] = skill_state
|
|
987
|
-
if skill_state == "exists":
|
|
988
|
-
entry["planned_actions"].append(
|
|
989
|
-
f"project Codex skill already present in {adapter.project_skill_rel}"
|
|
990
|
-
)
|
|
991
|
-
elif skill_state == "unsafe":
|
|
992
|
-
entry["planned_actions"].append(
|
|
993
|
-
f"refused unsafe project Codex skill target at {adapter.project_skill_rel}"
|
|
994
|
-
)
|
|
995
|
-
elif not with_skill:
|
|
996
|
-
entry["planned_actions"].append(
|
|
997
|
-
f"run with --with-skill to generate project Codex skill at {adapter.project_skill_rel}"
|
|
998
|
-
)
|
|
999
|
-
elif not applied:
|
|
1000
|
-
entry["planned_actions"].append(
|
|
1001
|
-
f"would generate project Codex skill at {adapter.project_skill_rel}"
|
|
1002
|
-
)
|
|
1003
|
-
else:
|
|
1004
|
-
skill_result = write_codex_project_skill(skill_path)
|
|
1005
|
-
entry["project_skill_status"] = skill_result["status"]
|
|
1006
|
-
if skill_result["status"] in {"applied", "updated"}:
|
|
1007
|
-
action = f"wrote project Codex skill to {adapter.project_skill_rel}"
|
|
1008
|
-
entry["applied_actions"].append(action)
|
|
1009
|
-
entry["planned_actions"].append(action)
|
|
1010
|
-
if entry["status"] in {"planned", "exists", "unchanged"}:
|
|
1011
|
-
entry["status"] = "applied"
|
|
1012
|
-
elif skill_result["status"] == "exists":
|
|
1013
|
-
entry["planned_actions"].append(
|
|
1014
|
-
f"project Codex skill already present in {adapter.project_skill_rel}"
|
|
1015
|
-
)
|
|
1016
|
-
else:
|
|
1017
|
-
entry["planned_actions"].append(skill_result.get("reason", "skipped"))
|
|
1018
|
-
elif adapter.capability == CapabilityClass.NATIVE_SKILL:
|
|
1019
|
-
entry["planned_actions"] = [adapter.summary]
|
|
1020
|
-
if brief_mode:
|
|
1021
|
-
entry["brief_mode_status"] = "unsupported"
|
|
1022
|
-
entry["brief_mode_reason"] = "adapter has no managed rule-file target"
|
|
1023
|
-
entry["planned_actions"].append(entry["brief_mode_reason"])
|
|
1024
|
-
else: # REPORT_ONLY
|
|
1025
|
-
entry["planned_actions"] = [adapter.summary]
|
|
1026
|
-
if brief_mode:
|
|
1027
|
-
entry["brief_mode_status"] = "unsupported"
|
|
1028
|
-
entry["brief_mode_reason"] = "adapter has no managed rule-file target"
|
|
1029
|
-
entry["planned_actions"].append(entry["brief_mode_reason"])
|
|
1030
|
-
plan.append(entry)
|
|
1031
|
-
return plan
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
class AtomicWriteDurabilityError(OSError):
|
|
1035
|
-
"""Raised after rename when the new file exists but directory durability is uncertain."""
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
def find_project_root(start: Path | None = None) -> Path:
|
|
1039
|
-
current = (start or Path.cwd()).expanduser().resolve()
|
|
1040
|
-
if current.is_file():
|
|
1041
|
-
current = current.parent
|
|
1042
|
-
for candidate in [current, *current.parents]:
|
|
1043
|
-
if (candidate / ".git").exists():
|
|
1044
|
-
return candidate
|
|
1045
|
-
return current
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
def resolve_setup_root(raw_root: str | None) -> Path:
|
|
1049
|
-
if raw_root is None:
|
|
1050
|
-
return find_project_root()
|
|
1051
|
-
root = Path(raw_root).expanduser().resolve()
|
|
1052
|
-
if not root.exists():
|
|
1053
|
-
raise SystemExit(f"Project root does not exist: {root}")
|
|
1054
|
-
return root.parent if root.is_file() else root
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
def normalize_scope(raw_scope: str | None) -> str:
|
|
1058
|
-
scope = str(raw_scope or "project").strip().lower()
|
|
1059
|
-
if scope == "global":
|
|
1060
|
-
return "user"
|
|
1061
|
-
if scope not in {"project", "user"}:
|
|
1062
|
-
raise SystemExit("Unknown setup scope: {!r}. Known scopes: project, user.".format(raw_scope))
|
|
1063
|
-
return scope
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
def resolve_scope_root(raw_root: str | None, scope: str) -> Path:
|
|
1067
|
-
if scope == "project":
|
|
1068
|
-
return resolve_setup_root(raw_root)
|
|
1069
|
-
home = Path.home().expanduser().resolve()
|
|
1070
|
-
if home == Path(home.anchor or "/"):
|
|
1071
|
-
raise SystemExit("Refusing user-scope setup because HOME resolves to a filesystem root.")
|
|
1072
|
-
if not home.exists() or not home.is_dir():
|
|
1073
|
-
raise SystemExit(f"Refusing user-scope setup because HOME is not a directory: {home}")
|
|
1074
|
-
return home
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
def explicit_agent_selection(args: argparse.Namespace) -> list[str] | None:
|
|
1078
|
-
values: list[str] = []
|
|
1079
|
-
for attr in ("agent", "only"):
|
|
1080
|
-
raw_values = getattr(args, attr, None)
|
|
1081
|
-
if not raw_values:
|
|
1082
|
-
continue
|
|
1083
|
-
for raw in raw_values:
|
|
1084
|
-
for part in str(raw).split(","):
|
|
1085
|
-
key = part.strip()
|
|
1086
|
-
if key:
|
|
1087
|
-
values.append(key)
|
|
1088
|
-
return values or None
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
def validate_settings_target(root: Path, settings_path: Path, *, allow_home_settings: bool) -> None:
|
|
1092
|
-
root = root.resolve()
|
|
1093
|
-
home_settings = Path.home().expanduser().resolve() / SETTINGS_REL
|
|
1094
|
-
if settings_path.expanduser().resolve() == home_settings and not allow_home_settings:
|
|
1095
|
-
raise SystemExit(
|
|
1096
|
-
"Refusing to modify global ~/.claude/settings.json. Run from a project directory, "
|
|
1097
|
-
"pass --root <project>, or use --allow-home-settings if you intentionally want this."
|
|
1098
|
-
)
|
|
1099
|
-
claude_dir = root / ".claude"
|
|
1100
|
-
if claude_dir.is_symlink():
|
|
1101
|
-
raise SystemExit(f"Refusing to use symlinked Claude settings directory: {claude_dir}")
|
|
1102
|
-
if settings_path.is_symlink():
|
|
1103
|
-
raise SystemExit(f"Refusing to write through symlinked settings file: {settings_path}")
|
|
1104
|
-
if claude_dir.exists():
|
|
1105
|
-
try:
|
|
1106
|
-
claude_dir.resolve().relative_to(root)
|
|
1107
|
-
except ValueError as exc:
|
|
1108
|
-
raise SystemExit(f"Claude settings directory resolves outside project root: {claude_dir}") from exc
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
def _base_open_flags() -> int:
|
|
1112
|
-
flags = os.O_RDONLY
|
|
1113
|
-
if hasattr(os, "O_CLOEXEC"):
|
|
1114
|
-
flags |= os.O_CLOEXEC
|
|
1115
|
-
return flags
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
def _no_follow_flag() -> int:
|
|
1119
|
-
if hasattr(os, "O_NOFOLLOW"):
|
|
1120
|
-
return os.O_NOFOLLOW
|
|
1121
|
-
raise OSError("platform does not support no-follow file opens")
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
def no_follow_file_ops_supported() -> bool:
|
|
1125
|
-
return (
|
|
1126
|
-
hasattr(os, "O_NOFOLLOW")
|
|
1127
|
-
and os.open in os.supports_dir_fd
|
|
1128
|
-
and os.mkdir in os.supports_dir_fd
|
|
1129
|
-
and os.rename in os.supports_dir_fd
|
|
1130
|
-
and os.unlink in os.supports_dir_fd
|
|
1131
|
-
)
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
def require_no_follow_file_ops_supported() -> None:
|
|
1135
|
-
if not no_follow_file_ops_supported() or fcntl is None:
|
|
1136
|
-
raise SystemExit(
|
|
1137
|
-
"Setup requires POSIX no-follow file operations for safe project-local settings writes; "
|
|
1138
|
-
"this platform is not supported yet."
|
|
1139
|
-
)
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
def _directory_flag() -> int:
|
|
1143
|
-
return getattr(os, "O_DIRECTORY", 0)
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
def _normalized_link_target(parent: Path, raw_target: str) -> Path:
|
|
1147
|
-
target = Path(raw_target)
|
|
1148
|
-
if not target.is_absolute():
|
|
1149
|
-
target = parent / target
|
|
1150
|
-
return Path(os.path.normpath(str(target)))
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
def _normalize_allowed_first_absolute_symlink(path: Path) -> Path:
|
|
1154
|
-
"""Rewrite narrow platform-owned absolute aliases before no-follow traversal."""
|
|
1155
|
-
if not path.is_absolute() or len(path.parts) < 2:
|
|
1156
|
-
return path
|
|
1157
|
-
first = path.parts[1]
|
|
1158
|
-
expected = ALLOWED_FIRST_ABSOLUTE_SYMLINKS.get(first)
|
|
1159
|
-
if expected is None:
|
|
1160
|
-
return path
|
|
1161
|
-
link = Path(path.anchor) / first
|
|
1162
|
-
try:
|
|
1163
|
-
if not stat.S_ISLNK(os.lstat(link).st_mode):
|
|
1164
|
-
return path
|
|
1165
|
-
if _normalized_link_target(Path(path.anchor), os.readlink(link)) != expected:
|
|
1166
|
-
return path
|
|
1167
|
-
except OSError:
|
|
1168
|
-
return path
|
|
1169
|
-
return expected.joinpath(*path.parts[2:])
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
def _open_directory_at(dir_fd: int, component: str, path: Path) -> int:
|
|
1173
|
-
flags = _base_open_flags() | _directory_flag() | _no_follow_flag()
|
|
1174
|
-
fd = os.open(component, flags, dir_fd=dir_fd)
|
|
1175
|
-
try:
|
|
1176
|
-
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
|
1177
|
-
raise OSError(f"not a directory: {path}")
|
|
1178
|
-
return fd
|
|
1179
|
-
except Exception:
|
|
1180
|
-
os.close(fd)
|
|
1181
|
-
raise
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
def _mkdir_directory_entry_at(dir_fd: int, component: str, mode: int) -> None:
|
|
1185
|
-
# mkdir modes are still filtered through umask. Run only the mkdir in an
|
|
1186
|
-
# isolated child process with umask 0 so the parent process umask never
|
|
1187
|
-
# changes, then the parent immediately reopens with O_NOFOLLOW.
|
|
1188
|
-
helper = (
|
|
1189
|
-
"import os, sys\n"
|
|
1190
|
-
"dir_fd = int(sys.argv[1])\n"
|
|
1191
|
-
"component = sys.argv[2]\n"
|
|
1192
|
-
"mode = int(sys.argv[3], 8)\n"
|
|
1193
|
-
"os.umask(0)\n"
|
|
1194
|
-
"os.mkdir(component, mode, dir_fd=dir_fd)\n"
|
|
1195
|
-
)
|
|
1196
|
-
proc = subprocess.run(
|
|
1197
|
-
[sys.executable, "-I", "-c", helper, str(dir_fd), component, oct(mode)],
|
|
1198
|
-
text=True,
|
|
1199
|
-
capture_output=True,
|
|
1200
|
-
pass_fds=(dir_fd,),
|
|
1201
|
-
)
|
|
1202
|
-
if proc.returncode != 0:
|
|
1203
|
-
detail = (proc.stderr or proc.stdout).strip().splitlines()[-1:] or [f"exit {proc.returncode}"]
|
|
1204
|
-
raise OSError(f"could not create directory component safely: {component}: {detail[0]}")
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
def _open_regular_no_symlink(path: Path) -> int:
|
|
1208
|
-
if os.open not in os.supports_dir_fd:
|
|
1209
|
-
raise OSError("platform does not support directory-relative no-follow opens")
|
|
1210
|
-
path = _normalize_allowed_first_absolute_symlink(path)
|
|
1211
|
-
components = list(path.parts)
|
|
1212
|
-
if path.is_absolute() and components:
|
|
1213
|
-
components = components[1:]
|
|
1214
|
-
if not components:
|
|
1215
|
-
raise OSError(f"not a regular file: {path}")
|
|
1216
|
-
|
|
1217
|
-
root = path.anchor if path.is_absolute() else "."
|
|
1218
|
-
dir_fd = os.open(root or ".", _base_open_flags() | _directory_flag())
|
|
1219
|
-
try:
|
|
1220
|
-
for component in components[:-1]:
|
|
1221
|
-
next_fd = _open_directory_at(dir_fd, component, path)
|
|
1222
|
-
os.close(dir_fd)
|
|
1223
|
-
dir_fd = next_fd
|
|
1224
|
-
|
|
1225
|
-
fd = os.open(components[-1], _base_open_flags() | _no_follow_flag(), dir_fd=dir_fd)
|
|
1226
|
-
try:
|
|
1227
|
-
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
1228
|
-
raise OSError(f"not a regular file: {path}")
|
|
1229
|
-
return fd
|
|
1230
|
-
except Exception:
|
|
1231
|
-
os.close(fd)
|
|
1232
|
-
raise
|
|
1233
|
-
finally:
|
|
1234
|
-
os.close(dir_fd)
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
def _ensure_directory_no_symlink(path: Path, mode: int | None = None, *, parents_mode: int | None = None) -> int:
|
|
1238
|
-
if os.mkdir not in os.supports_dir_fd:
|
|
1239
|
-
raise OSError("platform does not support directory-relative directory creation")
|
|
1240
|
-
path = _normalize_allowed_first_absolute_symlink(path)
|
|
1241
|
-
components = list(path.parts)
|
|
1242
|
-
if path.is_absolute() and components:
|
|
1243
|
-
components = components[1:]
|
|
1244
|
-
root = path.anchor if path.is_absolute() else "."
|
|
1245
|
-
dir_fd = os.open(root or ".", _base_open_flags() | _directory_flag())
|
|
1246
|
-
try:
|
|
1247
|
-
for index, component in enumerate(components):
|
|
1248
|
-
created = False
|
|
1249
|
-
mkdir_mode = (
|
|
1250
|
-
mode
|
|
1251
|
-
if mode is not None and index == len(components) - 1
|
|
1252
|
-
else (parents_mode if parents_mode is not None else PRIVATE_DIR_MODE)
|
|
1253
|
-
)
|
|
1254
|
-
try:
|
|
1255
|
-
next_fd = _open_directory_at(dir_fd, component, path)
|
|
1256
|
-
except FileNotFoundError:
|
|
1257
|
-
_mkdir_directory_entry_at(dir_fd, component, mkdir_mode)
|
|
1258
|
-
next_fd = _open_directory_at(dir_fd, component, path)
|
|
1259
|
-
created = True
|
|
1260
|
-
if created and hasattr(os, "fchmod"):
|
|
1261
|
-
os.fchmod(next_fd, mkdir_mode)
|
|
1262
|
-
os.close(dir_fd)
|
|
1263
|
-
dir_fd = next_fd
|
|
1264
|
-
return dir_fd
|
|
1265
|
-
except Exception:
|
|
1266
|
-
os.close(dir_fd)
|
|
1267
|
-
raise
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
def _read_text_no_follow(path: Path) -> str:
|
|
1271
|
-
fd = _open_regular_no_symlink(path)
|
|
1272
|
-
try:
|
|
1273
|
-
with os.fdopen(fd, "r", encoding="utf-8") as handle:
|
|
1274
|
-
fd = -1
|
|
1275
|
-
return handle.read()
|
|
1276
|
-
finally:
|
|
1277
|
-
if fd != -1:
|
|
1278
|
-
os.close(fd)
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
def _read_optional_text_no_follow(path: Path) -> str | None:
|
|
1282
|
-
try:
|
|
1283
|
-
return _read_text_no_follow(path)
|
|
1284
|
-
except FileNotFoundError:
|
|
1285
|
-
return None
|
|
1286
|
-
except OSError as exc:
|
|
1287
|
-
raise SystemExit(f"Could not read {path} without following symlinks: {exc}") from exc
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
def _path_exists_no_follow(path: Path) -> bool:
|
|
1291
|
-
try:
|
|
1292
|
-
path.lstat()
|
|
1293
|
-
except FileNotFoundError:
|
|
1294
|
-
return False
|
|
1295
|
-
except OSError:
|
|
1296
|
-
return False
|
|
1297
|
-
return True
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
def _parse_json_object_text(text: str | None, path: Path) -> dict[str, Any]:
|
|
1301
|
-
if text is None:
|
|
1302
|
-
return {}
|
|
1303
|
-
try:
|
|
1304
|
-
data = json.loads(text)
|
|
1305
|
-
except json.JSONDecodeError as exc:
|
|
1306
|
-
raise SystemExit(f"Invalid JSON in {path}: line {exc.lineno}: {exc.msg}") from exc
|
|
1307
|
-
if not isinstance(data, dict):
|
|
1308
|
-
raise SystemExit(f"Settings file must contain a JSON object: {path}")
|
|
1309
|
-
return data
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
def load_json_object(path: Path) -> dict[str, Any]:
|
|
1313
|
-
return _parse_json_object_text(_read_optional_text_no_follow(path), path)
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
def ensure_permissions(settings: dict[str, Any], actions: list[str]) -> None:
|
|
1317
|
-
permissions = settings.get("permissions")
|
|
1318
|
-
if permissions is None:
|
|
1319
|
-
permissions = {}
|
|
1320
|
-
settings["permissions"] = permissions
|
|
1321
|
-
if not isinstance(permissions, dict):
|
|
1322
|
-
raise SystemExit("Refusing to replace non-object settings.permissions; repair it manually first.")
|
|
1323
|
-
deny = permissions.get("deny")
|
|
1324
|
-
if deny is None:
|
|
1325
|
-
deny = []
|
|
1326
|
-
permissions["deny"] = deny
|
|
1327
|
-
if not isinstance(deny, list):
|
|
1328
|
-
raise SystemExit("Refusing to replace non-list settings.permissions.deny; repair it manually first.")
|
|
1329
|
-
added = 0
|
|
1330
|
-
for rule in RECOMMENDED_DENIES:
|
|
1331
|
-
if rule not in deny:
|
|
1332
|
-
deny.append(rule)
|
|
1333
|
-
added += 1
|
|
1334
|
-
if added:
|
|
1335
|
-
actions.append(f"added {added} permissions.deny rules for bulky/sensitive paths")
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
def command_values(value: Any) -> list[str]:
|
|
1339
|
-
found: list[str] = []
|
|
1340
|
-
if isinstance(value, dict):
|
|
1341
|
-
for key, item in value.items():
|
|
1342
|
-
if key == "command" and isinstance(item, str):
|
|
1343
|
-
found.append(item)
|
|
1344
|
-
found.extend(command_values(item))
|
|
1345
|
-
elif isinstance(value, list):
|
|
1346
|
-
for item in value:
|
|
1347
|
-
found.extend(command_values(item))
|
|
1348
|
-
return found
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
def matcher_covers(existing: Any, desired: str) -> bool:
|
|
1352
|
-
if not isinstance(existing, str):
|
|
1353
|
-
return False
|
|
1354
|
-
parts = {part.strip().lower() for part in existing.split("|") if part.strip()}
|
|
1355
|
-
return not parts or "*" in parts or desired.lower() in parts
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
def helper_argv(helper_name: str, kit_script: str, *, shell: str | None = None) -> list[str]:
|
|
1359
|
-
"""Return argv for a bundled helper without invoking a shell."""
|
|
1360
|
-
script_dir = Path(__file__).resolve().parent
|
|
1361
|
-
colocated = script_dir / helper_name
|
|
1362
|
-
if colocated.exists() and os.access(colocated, os.X_OK):
|
|
1363
|
-
return [str(colocated)]
|
|
1364
|
-
repo_plugin = script_dir.parent / "plugins" / "context-guard" / "bin" / helper_name
|
|
1365
|
-
if repo_plugin.exists() and os.access(repo_plugin, os.X_OK):
|
|
1366
|
-
return [str(repo_plugin)]
|
|
1367
|
-
kit_path = script_dir / kit_script
|
|
1368
|
-
if kit_path.exists():
|
|
1369
|
-
prefix = [shell] if shell else [sys.executable]
|
|
1370
|
-
return [*prefix, str(kit_path)]
|
|
1371
|
-
found = shutil.which(helper_name)
|
|
1372
|
-
if found:
|
|
1373
|
-
return [str(Path(found).resolve())]
|
|
1374
|
-
raise SystemExit(
|
|
1375
|
-
f"Could not resolve required helper {helper_name!r}; install the plugin or run from a checked-out repository."
|
|
1376
|
-
)
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
def helper_command(helper_name: str, kit_script: str, *, shell: str | None = None) -> str:
|
|
1380
|
-
"""hook 에 기록할 단일 셸 명령 문자열을 반환한다.
|
|
1381
|
-
|
|
1382
|
-
경로에 공백이나 셸 메타문자가 들어와도 안전하도록 모든 분기에서 `shlex.join` 으로
|
|
1383
|
-
quote 한다. PATH 에서 찾은 helper 도 절대 경로로 고정해 hook hijacking 을 막는다.
|
|
1384
|
-
"""
|
|
1385
|
-
argv = helper_argv(helper_name, kit_script, shell=shell)
|
|
1386
|
-
return shlex.join(argv)
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
def statusline_setting() -> dict[str, str]:
|
|
1390
|
-
return {"type": "command", "command": helper_command(HELPER_STATUSLINE, "statusline_merged.sh", shell="bash")}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
def bash_hook_setting() -> dict[str, Any]:
|
|
1394
|
-
return {
|
|
1395
|
-
"matcher": "Bash",
|
|
1396
|
-
"hooks": [{"type": "command", "command": helper_command(HELPER_REWRITE_BASH, "rewrite_bash_for_token_budget.py")}],
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
def read_hook_setting() -> dict[str, Any]:
|
|
1401
|
-
return {
|
|
1402
|
-
"matcher": "Read",
|
|
1403
|
-
"hooks": [{"type": "command", "command": helper_command(HELPER_GUARD_READ, "guard_large_read.py")}],
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
def failed_nudge_setting() -> dict[str, Any]:
|
|
1408
|
-
return {
|
|
1409
|
-
"matcher": "Bash",
|
|
1410
|
-
"hooks": [{"type": "command", "command": helper_command(HELPER_FAILED_NUDGE, "failed_attempt_nudge.py")}],
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
def command_matches(existing: str, desired: str) -> bool:
|
|
1415
|
-
if existing == desired:
|
|
1416
|
-
return True
|
|
1417
|
-
try:
|
|
1418
|
-
existing_parts = shlex.split(existing) if existing else []
|
|
1419
|
-
desired_parts = shlex.split(desired) if desired else []
|
|
1420
|
-
except ValueError:
|
|
1421
|
-
return False
|
|
1422
|
-
return bool(existing_parts and desired_parts and existing_parts == desired_parts)
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
def command_helper_basenames(command: str) -> set[str]:
|
|
1426
|
-
try:
|
|
1427
|
-
parts = shlex.split(command) if command else []
|
|
1428
|
-
except ValueError:
|
|
1429
|
-
return set()
|
|
1430
|
-
if not parts:
|
|
1431
|
-
return set()
|
|
1432
|
-
index = 0
|
|
1433
|
-
if os.path.basename(parts[index]) == "env":
|
|
1434
|
-
index += 1
|
|
1435
|
-
while index < len(parts) and "=" in parts[index] and not parts[index].startswith("-"):
|
|
1436
|
-
index += 1
|
|
1437
|
-
if index >= len(parts):
|
|
1438
|
-
return set()
|
|
1439
|
-
head = os.path.basename(parts[index])
|
|
1440
|
-
interpreter_heads = {"bash", "sh"}
|
|
1441
|
-
if re.fullmatch(r"python(?:\d+(?:\.\d+)?)?", head):
|
|
1442
|
-
interpreter_heads.add(head)
|
|
1443
|
-
if head in interpreter_heads:
|
|
1444
|
-
for token_index in range(index + 1, len(parts)):
|
|
1445
|
-
token = parts[token_index]
|
|
1446
|
-
if token == "-c":
|
|
1447
|
-
if token_index + 1 < len(parts):
|
|
1448
|
-
return command_helper_basenames(parts[token_index + 1])
|
|
1449
|
-
return set()
|
|
1450
|
-
if token.startswith("-"):
|
|
1451
|
-
continue
|
|
1452
|
-
return {os.path.basename(token)}
|
|
1453
|
-
return set()
|
|
1454
|
-
return {head}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
def equivalent_helper_basenames(command: str) -> set[str]:
|
|
1458
|
-
bases = command_helper_basenames(command)
|
|
1459
|
-
equivalents = set(bases)
|
|
1460
|
-
for base in bases:
|
|
1461
|
-
equivalents.update(HELPER_EQUIVALENT_BASENAMES.get(base, ()))
|
|
1462
|
-
return equivalents
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
def command_matches_existing_or_equivalent(existing: str, desired: str) -> bool:
|
|
1466
|
-
if command_matches(existing, desired):
|
|
1467
|
-
return True
|
|
1468
|
-
desired_helpers = equivalent_helper_basenames(desired)
|
|
1469
|
-
if not desired_helpers:
|
|
1470
|
-
return False
|
|
1471
|
-
return bool(command_helper_basenames(existing) & desired_helpers)
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
def canonicalize_equivalent_command(value: Any, desired: str) -> tuple[bool, bool]:
|
|
1475
|
-
"""Return (found_equivalent, changed), rewriting legacy/bare helpers to desired.
|
|
1476
|
-
|
|
1477
|
-
Older project settings may contain bare `claude-token-*` hook commands from
|
|
1478
|
-
the pre-ContextGuard plugin. Treating those as equivalent for deduplication
|
|
1479
|
-
is useful, but preserving them can leave Claude Code hooks pointing at a
|
|
1480
|
-
command that no longer exists on PATH. When a matching command field is
|
|
1481
|
-
found, pin it to the current canonical helper command instead.
|
|
1482
|
-
"""
|
|
1483
|
-
found = False
|
|
1484
|
-
changed = False
|
|
1485
|
-
if isinstance(value, dict):
|
|
1486
|
-
for key, item in value.items():
|
|
1487
|
-
if key == "command" and isinstance(item, str) and command_matches_existing_or_equivalent(item, desired):
|
|
1488
|
-
found = True
|
|
1489
|
-
if not command_matches(item, desired):
|
|
1490
|
-
value[key] = desired
|
|
1491
|
-
changed = True
|
|
1492
|
-
continue
|
|
1493
|
-
child_found, child_changed = canonicalize_equivalent_command(item, desired)
|
|
1494
|
-
found = found or child_found
|
|
1495
|
-
changed = changed or child_changed
|
|
1496
|
-
elif isinstance(value, list):
|
|
1497
|
-
for item in value:
|
|
1498
|
-
child_found, child_changed = canonicalize_equivalent_command(item, desired)
|
|
1499
|
-
found = found or child_found
|
|
1500
|
-
changed = changed or child_changed
|
|
1501
|
-
return found, changed
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
def has_hook_command(pre_tool_use: list[Any], matcher: str, command: str) -> bool:
|
|
1505
|
-
for entry in pre_tool_use:
|
|
1506
|
-
if not isinstance(entry, dict) or not matcher_covers(entry.get("matcher"), matcher):
|
|
1507
|
-
continue
|
|
1508
|
-
if any(command_matches_existing_or_equivalent(value, command) for value in command_values(entry)):
|
|
1509
|
-
return True
|
|
1510
|
-
return False
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
def ensure_pre_tool_hook(settings: dict[str, Any], hook: dict[str, Any], command: str, label: str, actions: list[str]) -> None:
|
|
1514
|
-
_ensure_tool_hook(settings, hook, command, label, actions, event="PreToolUse")
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
def ensure_post_tool_hook(settings: dict[str, Any], hook: dict[str, Any], command: str, label: str, actions: list[str]) -> None:
|
|
1518
|
-
_ensure_tool_hook(settings, hook, command, label, actions, event="PostToolUse")
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
def _ensure_tool_hook(
|
|
1522
|
-
settings: dict[str, Any],
|
|
1523
|
-
hook: dict[str, Any],
|
|
1524
|
-
command: str,
|
|
1525
|
-
label: str,
|
|
1526
|
-
actions: list[str],
|
|
1527
|
-
*,
|
|
1528
|
-
event: str,
|
|
1529
|
-
) -> None:
|
|
1530
|
-
hooks = settings.get("hooks")
|
|
1531
|
-
if hooks is None:
|
|
1532
|
-
hooks = {}
|
|
1533
|
-
settings["hooks"] = hooks
|
|
1534
|
-
if not isinstance(hooks, dict):
|
|
1535
|
-
raise SystemExit("Refusing to replace non-object settings.hooks; repair it manually first.")
|
|
1536
|
-
bucket = hooks.get(event)
|
|
1537
|
-
if bucket is None:
|
|
1538
|
-
bucket = []
|
|
1539
|
-
hooks[event] = bucket
|
|
1540
|
-
if not isinstance(bucket, list):
|
|
1541
|
-
raise SystemExit(f"Refusing to replace non-list settings.hooks.{event}; repair it manually first.")
|
|
1542
|
-
matcher = str(hook.get("matcher") or "")
|
|
1543
|
-
found_any = False
|
|
1544
|
-
changed_any = False
|
|
1545
|
-
for entry in bucket:
|
|
1546
|
-
if not isinstance(entry, dict) or not matcher_covers(entry.get("matcher"), matcher):
|
|
1547
|
-
continue
|
|
1548
|
-
found, changed = canonicalize_equivalent_command(entry, command)
|
|
1549
|
-
found_any = found_any or found
|
|
1550
|
-
changed_any = changed_any or changed
|
|
1551
|
-
if found_any:
|
|
1552
|
-
if changed_any:
|
|
1553
|
-
actions.append(f"migrated {label} hook to {command}")
|
|
1554
|
-
return
|
|
1555
|
-
bucket.append(copy.deepcopy(hook))
|
|
1556
|
-
actions.append(f"enabled {label} hook via {command}")
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
def summarize_diet_report(report: dict[str, Any]) -> dict[str, Any]:
|
|
1560
|
-
if not isinstance(report, dict):
|
|
1561
|
-
raise ValueError("report must be an object")
|
|
1562
|
-
raw_findings = report.get("findings", [])
|
|
1563
|
-
if not isinstance(raw_findings, list):
|
|
1564
|
-
raise ValueError("findings must be a list")
|
|
1565
|
-
findings: list[dict[str, Any]] = []
|
|
1566
|
-
for finding in raw_findings:
|
|
1567
|
-
if not isinstance(finding, dict):
|
|
1568
|
-
raise ValueError("findings must contain objects")
|
|
1569
|
-
findings.append(finding)
|
|
1570
|
-
|
|
1571
|
-
counts = {"high": 0, "medium": 0, "low": 0}
|
|
1572
|
-
for finding in findings:
|
|
1573
|
-
severity = str(finding.get("severity", "")).lower()
|
|
1574
|
-
if severity in counts:
|
|
1575
|
-
counts[severity] += 1
|
|
1576
|
-
top_findings = []
|
|
1577
|
-
for finding in findings[:DEFAULT_POST_SETUP_SCAN_TOP]:
|
|
1578
|
-
top_findings.append({
|
|
1579
|
-
"severity": finding.get("severity"),
|
|
1580
|
-
"id": finding.get("id"),
|
|
1581
|
-
"path": finding.get("path"),
|
|
1582
|
-
"message": finding.get("message"),
|
|
1583
|
-
"action": finding.get("action"),
|
|
1584
|
-
})
|
|
1585
|
-
raw_finding_count = report.get("finding_count", len(findings))
|
|
1586
|
-
try:
|
|
1587
|
-
finding_count = int(raw_finding_count)
|
|
1588
|
-
except (TypeError, ValueError) as exc:
|
|
1589
|
-
raise ValueError("finding_count must be an integer") from exc
|
|
1590
|
-
return {
|
|
1591
|
-
"status": "completed",
|
|
1592
|
-
"finding_count": finding_count,
|
|
1593
|
-
"severity_counts": counts,
|
|
1594
|
-
"top_findings": top_findings,
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
def run_post_setup_diet_scan(root: Path) -> dict[str, Any]:
|
|
1599
|
-
argv = [
|
|
1600
|
-
*helper_argv(HELPER_DIET, "context_guard_diet.py"),
|
|
1601
|
-
"scan",
|
|
1602
|
-
str(root),
|
|
1603
|
-
"--json",
|
|
1604
|
-
"--top",
|
|
1605
|
-
str(DEFAULT_POST_SETUP_SCAN_TOP),
|
|
1606
|
-
]
|
|
1607
|
-
try:
|
|
1608
|
-
proc = subprocess.run(
|
|
1609
|
-
argv,
|
|
1610
|
-
text=True,
|
|
1611
|
-
capture_output=True,
|
|
1612
|
-
check=False,
|
|
1613
|
-
timeout=POST_SETUP_SCAN_TIMEOUT_SECONDS,
|
|
1614
|
-
)
|
|
1615
|
-
except subprocess.TimeoutExpired:
|
|
1616
|
-
return {"status": "failed", "reason": "timeout", "timeout_seconds": POST_SETUP_SCAN_TIMEOUT_SECONDS}
|
|
1617
|
-
except UnicodeError:
|
|
1618
|
-
return {"status": "failed", "reason": "decode-error"}
|
|
1619
|
-
except OSError as exc:
|
|
1620
|
-
return {"status": "failed", "reason": exc.__class__.__name__}
|
|
1621
|
-
if proc.returncode != 0:
|
|
1622
|
-
return {"status": "failed", "reason": "nonzero-exit", "returncode": proc.returncode}
|
|
1623
|
-
try:
|
|
1624
|
-
report = json.loads(proc.stdout)
|
|
1625
|
-
except json.JSONDecodeError:
|
|
1626
|
-
return {"status": "failed", "reason": "invalid-json"}
|
|
1627
|
-
try:
|
|
1628
|
-
return summarize_diet_report(report)
|
|
1629
|
-
except ValueError:
|
|
1630
|
-
return {"status": "failed", "reason": "invalid-report"}
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
def doctor_check(
|
|
1634
|
-
ident: str,
|
|
1635
|
-
status: str,
|
|
1636
|
-
severity: str,
|
|
1637
|
-
message: str,
|
|
1638
|
-
*,
|
|
1639
|
-
detail: Any | None = None,
|
|
1640
|
-
next_action: str | None = None,
|
|
1641
|
-
) -> dict[str, Any]:
|
|
1642
|
-
check = {
|
|
1643
|
-
"id": ident,
|
|
1644
|
-
"status": status,
|
|
1645
|
-
"severity": severity,
|
|
1646
|
-
"message": message,
|
|
1647
|
-
}
|
|
1648
|
-
if detail is not None:
|
|
1649
|
-
check["detail"] = detail
|
|
1650
|
-
if next_action:
|
|
1651
|
-
check["next_action"] = next_action
|
|
1652
|
-
return check
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
def _setup_command(args: argparse.Namespace, *, apply: bool, root: Path | None = None) -> str:
|
|
1656
|
-
parts = ["context-guard", "setup", "--scope", normalize_scope(getattr(args, "scope", "project"))]
|
|
1657
|
-
if root is not None and normalize_scope(getattr(args, "scope", "project")) == "project":
|
|
1658
|
-
parts.extend(["--root", str(root)])
|
|
1659
|
-
selected = explicit_agent_selection(args)
|
|
1660
|
-
if selected:
|
|
1661
|
-
parts.extend(["--agent", ",".join(selected)])
|
|
1662
|
-
elif normalize_scope(getattr(args, "scope", "project")) == "user":
|
|
1663
|
-
parts.extend(["--agent", "claude"])
|
|
1664
|
-
if getattr(args, "with_init", False):
|
|
1665
|
-
parts.append("--with-init")
|
|
1666
|
-
if getattr(args, "with_skill", False):
|
|
1667
|
-
parts.append("--with-skill")
|
|
1668
|
-
brief_mode = getattr(args, "brief_mode", None)
|
|
1669
|
-
if brief_mode:
|
|
1670
|
-
parts.extend(["--brief-mode", str(brief_mode)])
|
|
1671
|
-
for attr, flag in (
|
|
1672
|
-
("no_denies", "--no-denies"),
|
|
1673
|
-
("no_statusline", "--no-statusline"),
|
|
1674
|
-
("no_bash_hook", "--no-bash-hook"),
|
|
1675
|
-
("no_read_guard", "--no-read-guard"),
|
|
1676
|
-
("no_model_defaults", "--no-model-defaults"),
|
|
1677
|
-
("no_diet_scan", "--no-diet-scan"),
|
|
1678
|
-
):
|
|
1679
|
-
if getattr(args, attr, False):
|
|
1680
|
-
parts.append(flag)
|
|
1681
|
-
if getattr(args, "failed_attempt_nudge", None) is False:
|
|
1682
|
-
parts.append("--no-failed-attempt-nudge")
|
|
1683
|
-
elif getattr(args, "failed_attempt_nudge", None) is True:
|
|
1684
|
-
parts.append("--failed-attempt-nudge")
|
|
1685
|
-
parts.append("--yes" if apply else "--plan")
|
|
1686
|
-
return shlex.join(parts)
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
def _doctor_status(checks: list[dict[str, Any]]) -> str:
|
|
1690
|
-
if any(check.get("status") == "error" or check.get("severity") == "error" for check in checks):
|
|
1691
|
-
return "error"
|
|
1692
|
-
if any(check.get("status") == "warning" or check.get("severity") in {"high", "medium"} for check in checks):
|
|
1693
|
-
return "warning"
|
|
1694
|
-
return "ok"
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
def _helper_availability_check(*, include_diet: bool = True) -> dict[str, Any]:
|
|
1698
|
-
helpers = {
|
|
1699
|
-
HELPER_STATUSLINE: "statusline_merged.sh",
|
|
1700
|
-
HELPER_REWRITE_BASH: "rewrite_bash_for_token_budget.py",
|
|
1701
|
-
HELPER_GUARD_READ: "guard_large_read.py",
|
|
1702
|
-
HELPER_FAILED_NUDGE: "failed_attempt_nudge.py",
|
|
1703
|
-
}
|
|
1704
|
-
if include_diet:
|
|
1705
|
-
helpers[HELPER_DIET] = "context_guard_diet.py"
|
|
1706
|
-
resolved: dict[str, str] = {}
|
|
1707
|
-
missing: list[str] = []
|
|
1708
|
-
for helper, kit_script in helpers.items():
|
|
1709
|
-
try:
|
|
1710
|
-
resolved[helper] = shlex.join(helper_argv(helper, kit_script, shell=("bash" if kit_script.endswith(".sh") else None)))
|
|
1711
|
-
except SystemExit:
|
|
1712
|
-
missing.append(helper)
|
|
1713
|
-
if missing:
|
|
1714
|
-
return doctor_check(
|
|
1715
|
-
"helper-availability",
|
|
1716
|
-
"error",
|
|
1717
|
-
"error",
|
|
1718
|
-
"Some ContextGuard helper commands could not be resolved.",
|
|
1719
|
-
detail={"missing": missing, "resolved": resolved},
|
|
1720
|
-
next_action="Reinstall ContextGuard or run from a complete checkout.",
|
|
1721
|
-
)
|
|
1722
|
-
return doctor_check(
|
|
1723
|
-
"helper-availability",
|
|
1724
|
-
"ok",
|
|
1725
|
-
"low",
|
|
1726
|
-
"Required ContextGuard helper commands are resolvable.",
|
|
1727
|
-
detail={"resolved": resolved},
|
|
1728
|
-
)
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
def _adapter_warning_detail(entry: dict[str, Any]) -> dict[str, Any]:
|
|
1732
|
-
detail = {
|
|
1733
|
-
"key": entry.get("key"),
|
|
1734
|
-
"status": entry.get("status"),
|
|
1735
|
-
"planned_actions": entry.get("planned_actions", []),
|
|
1736
|
-
"unsupported_reason": entry.get("unsupported_reason"),
|
|
1737
|
-
}
|
|
1738
|
-
for key in ("brief_mode", "brief_mode_status", "brief_mode_reason", "brief_mode_file"):
|
|
1739
|
-
if key in entry:
|
|
1740
|
-
detail[key] = entry.get(key)
|
|
1741
|
-
return detail
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
def run_doctor(args: argparse.Namespace) -> dict[str, Any]:
|
|
1745
|
-
"""Return a read-only setup health report.
|
|
1746
|
-
|
|
1747
|
-
This intentionally mirrors setup planning while never prompting, backing up,
|
|
1748
|
-
writing settings, writing rule files, or creating rollback records.
|
|
1749
|
-
"""
|
|
1750
|
-
require_no_follow_file_ops_supported()
|
|
1751
|
-
scope = normalize_scope(getattr(args, "scope", "project"))
|
|
1752
|
-
root = resolve_scope_root(args.root, scope)
|
|
1753
|
-
settings_path = root / SETTINGS_REL
|
|
1754
|
-
helper_check = _helper_availability_check(include_diet=not getattr(args, "no_diet_scan", False))
|
|
1755
|
-
checks: list[dict[str, Any]] = [helper_check]
|
|
1756
|
-
warnings: list[str] = []
|
|
1757
|
-
if scope == "user":
|
|
1758
|
-
warnings.append("user-scope verify is read-only; applying user-scope setup still requires --yes and an explicit agent")
|
|
1759
|
-
|
|
1760
|
-
selected_agents = explicit_agent_selection(args)
|
|
1761
|
-
targets = resolve_target_adapters(root, selected_agents)
|
|
1762
|
-
claude_targeted = any(adapter.key == "claude" for adapter in targets)
|
|
1763
|
-
|
|
1764
|
-
original_text = None
|
|
1765
|
-
original: dict[str, Any] = {}
|
|
1766
|
-
settings: dict[str, Any] = {}
|
|
1767
|
-
if claude_targeted:
|
|
1768
|
-
try:
|
|
1769
|
-
validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
|
|
1770
|
-
original_text = _read_optional_text_no_follow(settings_path)
|
|
1771
|
-
original = _parse_json_object_text(original_text, settings_path)
|
|
1772
|
-
settings = json.loads(json.dumps(original))
|
|
1773
|
-
checks.append(doctor_check(
|
|
1774
|
-
"settings-target",
|
|
1775
|
-
"ok",
|
|
1776
|
-
"low",
|
|
1777
|
-
"Claude settings target is readable without following symlinks.",
|
|
1778
|
-
detail={
|
|
1779
|
-
"exists": original_text is not None,
|
|
1780
|
-
"path": str(settings_path),
|
|
1781
|
-
},
|
|
1782
|
-
))
|
|
1783
|
-
except SystemExit as exc:
|
|
1784
|
-
checks.append(doctor_check(
|
|
1785
|
-
"settings-target",
|
|
1786
|
-
"error",
|
|
1787
|
-
"error",
|
|
1788
|
-
"Claude settings target could not be read as a safe JSON object.",
|
|
1789
|
-
detail={
|
|
1790
|
-
"exists": _path_exists_no_follow(settings_path),
|
|
1791
|
-
"path": str(settings_path),
|
|
1792
|
-
"error": str(exc),
|
|
1793
|
-
},
|
|
1794
|
-
next_action=f"Fix or remove {settings_path} before running setup or verify again.",
|
|
1795
|
-
))
|
|
1796
|
-
return {
|
|
1797
|
-
"schema_version": "contextguard.doctor.v1",
|
|
1798
|
-
"status": "error",
|
|
1799
|
-
"root": str(root),
|
|
1800
|
-
"scope": scope,
|
|
1801
|
-
"settings_path": str(settings_path),
|
|
1802
|
-
"read_only": True,
|
|
1803
|
-
"warnings": warnings,
|
|
1804
|
-
"checks": checks,
|
|
1805
|
-
"setup_plan": {
|
|
1806
|
-
"changed": False,
|
|
1807
|
-
"actions": [],
|
|
1808
|
-
"adapter_plan": [],
|
|
1809
|
-
},
|
|
1810
|
-
"diet_scan": {"status": "skipped", "reason": "settings-target-error"},
|
|
1811
|
-
"recommended_commands": [],
|
|
1812
|
-
}
|
|
1813
|
-
else:
|
|
1814
|
-
checks.append(doctor_check(
|
|
1815
|
-
"settings-target",
|
|
1816
|
-
"ok",
|
|
1817
|
-
"low",
|
|
1818
|
-
"Claude settings target was not requested for selected adapters.",
|
|
1819
|
-
detail={"path": str(settings_path)},
|
|
1820
|
-
))
|
|
1821
|
-
|
|
1822
|
-
if helper_check.get("status") == "error":
|
|
1823
|
-
diet_scan = {"status": "skipped", "reason": "helper-unavailable"}
|
|
1824
|
-
return {
|
|
1825
|
-
"schema_version": "contextguard.doctor.v1",
|
|
1826
|
-
"status": "error",
|
|
1827
|
-
"root": str(root),
|
|
1828
|
-
"scope": scope,
|
|
1829
|
-
"settings_path": str(settings_path),
|
|
1830
|
-
"read_only": True,
|
|
1831
|
-
"warnings": warnings,
|
|
1832
|
-
"checks": checks,
|
|
1833
|
-
"setup_plan": {
|
|
1834
|
-
"changed": False,
|
|
1835
|
-
"actions": [],
|
|
1836
|
-
"adapter_plan": [],
|
|
1837
|
-
},
|
|
1838
|
-
"diet_scan": diet_scan,
|
|
1839
|
-
"recommended_commands": [],
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
choices = choices_from_args(args)
|
|
1843
|
-
actions = apply_choices(settings, choices) if claude_targeted else []
|
|
1844
|
-
changed = (settings != original) if claude_targeted else False
|
|
1845
|
-
if changed:
|
|
1846
|
-
checks.append(doctor_check(
|
|
1847
|
-
"setup-plan",
|
|
1848
|
-
"warning",
|
|
1849
|
-
"medium",
|
|
1850
|
-
"ContextGuard setup is not fully applied for the requested selections.",
|
|
1851
|
-
detail={"planned_action_count": len(actions), "planned_actions": actions},
|
|
1852
|
-
next_action=_setup_command(args, apply=False, root=root),
|
|
1853
|
-
))
|
|
1854
|
-
else:
|
|
1855
|
-
checks.append(doctor_check(
|
|
1856
|
-
"setup-plan",
|
|
1857
|
-
"ok",
|
|
1858
|
-
"low",
|
|
1859
|
-
"Requested setup settings are already satisfied.",
|
|
1860
|
-
detail={"planned_action_count": 0},
|
|
1861
|
-
))
|
|
1862
|
-
|
|
1863
|
-
adapter_plan = build_adapter_plan(
|
|
1864
|
-
root,
|
|
1865
|
-
targets,
|
|
1866
|
-
scope=scope,
|
|
1867
|
-
claude_actions=actions,
|
|
1868
|
-
claude_changed=changed,
|
|
1869
|
-
claude_applied=False,
|
|
1870
|
-
with_init=bool(getattr(args, "with_init", False)),
|
|
1871
|
-
with_skill=bool(getattr(args, "with_skill", False)),
|
|
1872
|
-
applied=False,
|
|
1873
|
-
brief_mode=getattr(args, "brief_mode", None),
|
|
1874
|
-
)
|
|
1875
|
-
adapter_warnings = [
|
|
1876
|
-
_adapter_warning_detail(entry)
|
|
1877
|
-
for entry in adapter_plan
|
|
1878
|
-
if entry.get("status") in {"planned", "unsupported", "skipped"}
|
|
1879
|
-
]
|
|
1880
|
-
if adapter_warnings:
|
|
1881
|
-
checks.append(doctor_check(
|
|
1882
|
-
"adapter-plan",
|
|
1883
|
-
"warning",
|
|
1884
|
-
"medium",
|
|
1885
|
-
"Some requested adapters still have planned or unsupported setup actions.",
|
|
1886
|
-
detail={"adapters": adapter_warnings},
|
|
1887
|
-
next_action=_setup_command(args, apply=False, root=root),
|
|
1888
|
-
))
|
|
1889
|
-
else:
|
|
1890
|
-
checks.append(doctor_check(
|
|
1891
|
-
"adapter-plan",
|
|
1892
|
-
"ok",
|
|
1893
|
-
"low",
|
|
1894
|
-
"Requested adapter setup plan has no pending supported writes.",
|
|
1895
|
-
detail={"adapter_count": len(adapter_plan)},
|
|
1896
|
-
))
|
|
1897
|
-
|
|
1898
|
-
diet_scan = None
|
|
1899
|
-
if getattr(args, "no_diet_scan", False):
|
|
1900
|
-
diet_scan = {"status": "skipped", "reason": "disabled-by-flag"}
|
|
1901
|
-
checks.append(doctor_check(
|
|
1902
|
-
"diet-scan",
|
|
1903
|
-
"ok",
|
|
1904
|
-
"low",
|
|
1905
|
-
"Context hygiene scan was skipped by flag.",
|
|
1906
|
-
detail=diet_scan,
|
|
1907
|
-
))
|
|
1908
|
-
else:
|
|
1909
|
-
diet_next_action = shlex.join(["context-guard", "diet", "scan", str(root), "--json"])
|
|
1910
|
-
diet_scan = run_post_setup_diet_scan(root)
|
|
1911
|
-
if diet_scan.get("status") != "completed":
|
|
1912
|
-
checks.append(doctor_check(
|
|
1913
|
-
"diet-scan",
|
|
1914
|
-
"warning",
|
|
1915
|
-
"medium",
|
|
1916
|
-
"Context hygiene scan could not complete.",
|
|
1917
|
-
detail=diet_scan,
|
|
1918
|
-
next_action=diet_next_action,
|
|
1919
|
-
))
|
|
1920
|
-
else:
|
|
1921
|
-
counts = diet_scan.get("severity_counts", {})
|
|
1922
|
-
high_medium = int(counts.get("high", 0) or 0) + int(counts.get("medium", 0) or 0)
|
|
1923
|
-
if high_medium:
|
|
1924
|
-
checks.append(doctor_check(
|
|
1925
|
-
"diet-scan",
|
|
1926
|
-
"warning",
|
|
1927
|
-
"medium",
|
|
1928
|
-
"Context hygiene scan found high/medium findings.",
|
|
1929
|
-
detail=diet_scan,
|
|
1930
|
-
next_action=diet_next_action,
|
|
1931
|
-
))
|
|
1932
|
-
else:
|
|
1933
|
-
checks.append(doctor_check(
|
|
1934
|
-
"diet-scan",
|
|
1935
|
-
"ok",
|
|
1936
|
-
"low",
|
|
1937
|
-
"Context hygiene scan has no high/medium findings.",
|
|
1938
|
-
detail=diet_scan,
|
|
1939
|
-
))
|
|
1940
|
-
|
|
1941
|
-
recommended = [_setup_command(args, apply=False, root=root)]
|
|
1942
|
-
if changed or adapter_warnings:
|
|
1943
|
-
recommended.append(_setup_command(args, apply=True, root=root))
|
|
1944
|
-
return {
|
|
1945
|
-
"schema_version": "contextguard.doctor.v1",
|
|
1946
|
-
"status": _doctor_status(checks),
|
|
1947
|
-
"root": str(root),
|
|
1948
|
-
"scope": scope,
|
|
1949
|
-
"settings_path": str(settings_path),
|
|
1950
|
-
"read_only": True,
|
|
1951
|
-
"warnings": warnings,
|
|
1952
|
-
"checks": checks,
|
|
1953
|
-
"setup_plan": {
|
|
1954
|
-
"changed": changed,
|
|
1955
|
-
"actions": actions,
|
|
1956
|
-
"adapter_plan": adapter_plan,
|
|
1957
|
-
},
|
|
1958
|
-
"diet_scan": diet_scan,
|
|
1959
|
-
"recommended_commands": recommended,
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
def render_doctor_text(report: dict[str, Any]) -> str:
|
|
1964
|
-
lines = [
|
|
1965
|
-
f"ContextGuard doctor ({report.get('status', 'unknown')})",
|
|
1966
|
-
"read-only health check; no changes made",
|
|
1967
|
-
f"scope={report.get('scope')}",
|
|
1968
|
-
f"root={report.get('root')}",
|
|
1969
|
-
f"settings={report.get('settings_path')}",
|
|
1970
|
-
]
|
|
1971
|
-
warnings = report.get("warnings") or []
|
|
1972
|
-
if warnings:
|
|
1973
|
-
lines.append("warnings:")
|
|
1974
|
-
lines.extend(f"- {warning}" for warning in warnings)
|
|
1975
|
-
lines.append("checks:")
|
|
1976
|
-
for check in report.get("checks", []):
|
|
1977
|
-
lines.append(
|
|
1978
|
-
f"- [{str(check.get('status', '')).upper()}] {check.get('id')}: {check.get('message')}"
|
|
1979
|
-
)
|
|
1980
|
-
if check.get("next_action"):
|
|
1981
|
-
lines.append(f" next: {check['next_action']}")
|
|
1982
|
-
commands = report.get("recommended_commands") or []
|
|
1983
|
-
if commands:
|
|
1984
|
-
lines.append("recommended next commands:")
|
|
1985
|
-
lines.extend(f"- {command}" for command in commands)
|
|
1986
|
-
return "\n".join(lines) + "\n"
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
def apply_choices(settings: dict[str, Any], choices: Choices) -> list[str]:
|
|
1990
|
-
actions: list[str] = []
|
|
1991
|
-
if choices.model_defaults:
|
|
1992
|
-
if not settings.get("model"):
|
|
1993
|
-
settings["model"] = DEFAULT_MODEL
|
|
1994
|
-
actions.append(f"set default model to {DEFAULT_MODEL}")
|
|
1995
|
-
if not settings.get("effortLevel"):
|
|
1996
|
-
settings["effortLevel"] = DEFAULT_EFFORT
|
|
1997
|
-
actions.append(f"set default effortLevel to {DEFAULT_EFFORT}")
|
|
1998
|
-
if choices.statusline:
|
|
1999
|
-
statusline = statusline_setting()
|
|
2000
|
-
if "statusLine" not in settings:
|
|
2001
|
-
settings["statusLine"] = statusline
|
|
2002
|
-
actions.append("enabled token statusline")
|
|
2003
|
-
elif settings.get("statusLine") != statusline:
|
|
2004
|
-
actions.append("kept existing statusLine; add context-guard-statusline-merged manually if desired")
|
|
2005
|
-
if choices.denies:
|
|
2006
|
-
ensure_permissions(settings, actions)
|
|
2007
|
-
if choices.bash_hook:
|
|
2008
|
-
bash_hook = bash_hook_setting()
|
|
2009
|
-
bash_command = bash_hook["hooks"][0]["command"]
|
|
2010
|
-
ensure_pre_tool_hook(settings, bash_hook, bash_command, "Bash trim/sanitize", actions)
|
|
2011
|
-
if choices.read_guard:
|
|
2012
|
-
read_hook = read_hook_setting()
|
|
2013
|
-
read_command = read_hook["hooks"][0]["command"]
|
|
2014
|
-
ensure_pre_tool_hook(settings, read_hook, read_command, "large Read guard", actions)
|
|
2015
|
-
if choices.failed_attempt_nudge:
|
|
2016
|
-
nudge_hook = failed_nudge_setting()
|
|
2017
|
-
nudge_command = nudge_hook["hooks"][0]["command"]
|
|
2018
|
-
ensure_post_tool_hook(settings, nudge_hook, nudge_command, "failed-attempt /clear nudge", actions)
|
|
2019
|
-
return actions
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
def atomic_write(path: Path, text: str, mode: int = 0o600, *, dir_mode: int = PRIVATE_DIR_MODE) -> None:
|
|
2023
|
-
if os.rename not in os.supports_dir_fd or os.unlink not in os.supports_dir_fd:
|
|
2024
|
-
raise OSError("platform does not support directory-relative atomic writes")
|
|
2025
|
-
parent_fd = _ensure_directory_no_symlink(path.parent, dir_mode, parents_mode=dir_mode)
|
|
2026
|
-
tmp_name = f".{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp"
|
|
2027
|
-
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | _no_follow_flag()
|
|
2028
|
-
fd = os.open(tmp_name, flags, mode, dir_fd=parent_fd)
|
|
2029
|
-
try:
|
|
2030
|
-
if hasattr(os, "fchmod"):
|
|
2031
|
-
os.fchmod(fd, mode)
|
|
2032
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
2033
|
-
fd = -1
|
|
2034
|
-
f.write(text)
|
|
2035
|
-
f.flush()
|
|
2036
|
-
os.fsync(f.fileno())
|
|
2037
|
-
os.fsync(parent_fd)
|
|
2038
|
-
os.rename(tmp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
|
|
2039
|
-
try:
|
|
2040
|
-
os.fsync(parent_fd)
|
|
2041
|
-
except OSError as exc:
|
|
2042
|
-
raise AtomicWriteDurabilityError(
|
|
2043
|
-
f"write committed but parent directory durability is uncertain: {path}"
|
|
2044
|
-
) from exc
|
|
2045
|
-
finally:
|
|
2046
|
-
if fd != -1:
|
|
2047
|
-
os.close(fd)
|
|
2048
|
-
try:
|
|
2049
|
-
os.unlink(tmp_name, dir_fd=parent_fd)
|
|
2050
|
-
except FileNotFoundError:
|
|
2051
|
-
pass
|
|
2052
|
-
os.close(parent_fd)
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
def existing_mode_or_default(path: Path, default: int = 0o600) -> int:
|
|
2056
|
-
try:
|
|
2057
|
-
fd = _open_regular_no_symlink(path)
|
|
2058
|
-
except FileNotFoundError:
|
|
2059
|
-
return default
|
|
2060
|
-
except OSError:
|
|
2061
|
-
return default
|
|
2062
|
-
try:
|
|
2063
|
-
return os.fstat(fd).st_mode & 0o777
|
|
2064
|
-
finally:
|
|
2065
|
-
os.close(fd)
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
def backup_existing(path: Path) -> Path | None:
|
|
2069
|
-
try:
|
|
2070
|
-
text = _read_text_no_follow(path)
|
|
2071
|
-
except FileNotFoundError:
|
|
2072
|
-
return None
|
|
2073
|
-
mode = existing_mode_or_default(path, 0o600)
|
|
2074
|
-
stamp = _dt.datetime.now().strftime("%Y%m%d%H%M%S%f")
|
|
2075
|
-
backup = path.with_name(f"{path.name}.bak-{stamp}-{uuid.uuid4().hex[:8]}")
|
|
2076
|
-
atomic_write(backup, text, mode)
|
|
2077
|
-
return backup
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
def write_rollback_record(
|
|
2081
|
-
*,
|
|
2082
|
-
root: Path,
|
|
2083
|
-
scope: str,
|
|
2084
|
-
settings_path: Path,
|
|
2085
|
-
backup_path: Path | None,
|
|
2086
|
-
original_existed: bool,
|
|
2087
|
-
) -> tuple[str | None, Path | None]:
|
|
2088
|
-
"""Record a minimal rollback handle for user-scope writes.
|
|
2089
|
-
|
|
2090
|
-
Project-scope setup keeps the legacy backup-only behavior. User-scope setup
|
|
2091
|
-
can affect many future projects, so every write gets a local rollback record
|
|
2092
|
-
under the user's ContextGuard state directory.
|
|
2093
|
-
"""
|
|
2094
|
-
if scope != "user":
|
|
2095
|
-
return None, None
|
|
2096
|
-
rollback_id = _dt.datetime.now().strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:8]
|
|
2097
|
-
rollback_dir = root / ".context-guard" / "rollback"
|
|
2098
|
-
rollback_path = rollback_dir / f"{rollback_id}.json"
|
|
2099
|
-
record = {
|
|
2100
|
-
"schema_version": "contextguard.rollback.v1",
|
|
2101
|
-
"rollback_id": rollback_id,
|
|
2102
|
-
"created_at": _dt.datetime.now(_dt.UTC).isoformat().replace("+00:00", "Z"),
|
|
2103
|
-
"scope": scope,
|
|
2104
|
-
"target_path": str(settings_path),
|
|
2105
|
-
"backup_path": str(backup_path) if backup_path else None,
|
|
2106
|
-
"original_existed": original_existed,
|
|
2107
|
-
"restore": (
|
|
2108
|
-
f"cp {shlex.quote(str(backup_path))} {shlex.quote(str(settings_path))}"
|
|
2109
|
-
if backup_path
|
|
2110
|
-
else f"rm -f {shlex.quote(str(settings_path))}"
|
|
2111
|
-
),
|
|
2112
|
-
}
|
|
2113
|
-
atomic_write(rollback_path, json.dumps(record, indent=2, sort_keys=True) + "\n", 0o600)
|
|
2114
|
-
return rollback_id, rollback_path
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
def acquire_settings_lock(path: Path) -> int:
|
|
2118
|
-
"""Take an exclusive project-local settings lock without following links."""
|
|
2119
|
-
if fcntl is None:
|
|
2120
|
-
raise OSError("platform does not support advisory file locks")
|
|
2121
|
-
parent_fd = _ensure_directory_no_symlink(path.parent, PRIVATE_DIR_MODE)
|
|
2122
|
-
lock_name = f".{path.name}.lock"
|
|
2123
|
-
flags = os.O_CREAT | os.O_RDWR | _no_follow_flag()
|
|
2124
|
-
if hasattr(os, "O_CLOEXEC"):
|
|
2125
|
-
flags |= os.O_CLOEXEC
|
|
2126
|
-
try:
|
|
2127
|
-
fd = os.open(lock_name, flags, 0o600, dir_fd=parent_fd)
|
|
2128
|
-
finally:
|
|
2129
|
-
os.close(parent_fd)
|
|
2130
|
-
try:
|
|
2131
|
-
st = os.fstat(fd)
|
|
2132
|
-
if not stat.S_ISREG(st.st_mode):
|
|
2133
|
-
raise OSError(f"settings lock is not a regular file: {path.with_name(lock_name)}")
|
|
2134
|
-
if hasattr(os, "fchmod"):
|
|
2135
|
-
os.fchmod(fd, 0o600)
|
|
2136
|
-
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
2137
|
-
return fd
|
|
2138
|
-
except Exception:
|
|
2139
|
-
os.close(fd)
|
|
2140
|
-
raise
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
def release_settings_lock(fd: int) -> None:
|
|
2144
|
-
try:
|
|
2145
|
-
if fcntl is not None:
|
|
2146
|
-
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
2147
|
-
finally:
|
|
2148
|
-
os.close(fd)
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
def prompt_bool(question: str, default: bool) -> bool:
|
|
2152
|
-
suffix = "Y/n" if default else "y/N"
|
|
2153
|
-
while True:
|
|
2154
|
-
answer = input(f"{question} [{suffix}] ").strip().lower()
|
|
2155
|
-
if not answer:
|
|
2156
|
-
return default
|
|
2157
|
-
if answer in {"y", "yes"}:
|
|
2158
|
-
return True
|
|
2159
|
-
if answer in {"n", "no"}:
|
|
2160
|
-
return False
|
|
2161
|
-
print("Please answer y or n.")
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
def interactive_choices(defaults: Choices) -> Choices:
|
|
2165
|
-
print("ContextGuard setup wizard")
|
|
2166
|
-
print("Project-local changes only. Existing settings are merged, not replaced.\n")
|
|
2167
|
-
choices = Choices(
|
|
2168
|
-
denies=prompt_bool("Add deny rules for bulky/sensitive paths?", defaults.denies),
|
|
2169
|
-
statusline=prompt_bool("Enable token/cost statusline?", defaults.statusline),
|
|
2170
|
-
bash_hook=prompt_bool("Enable Bash output trim + grep/diff sanitizer hook?", defaults.bash_hook),
|
|
2171
|
-
read_guard=prompt_bool("Enable large Read guard?", defaults.read_guard),
|
|
2172
|
-
model_defaults=prompt_bool("Set missing defaults to model=sonnet and effortLevel=medium?", defaults.model_defaults),
|
|
2173
|
-
failed_attempt_nudge=prompt_bool(
|
|
2174
|
-
"Enable failed-attempt /clear nudge? (PostToolUse hook on Bash; recommended default)",
|
|
2175
|
-
defaults.failed_attempt_nudge,
|
|
2176
|
-
),
|
|
2177
|
-
)
|
|
2178
|
-
return choices
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
def choices_from_args(args: argparse.Namespace) -> Choices:
|
|
2182
|
-
return Choices(
|
|
2183
|
-
denies=not args.no_denies,
|
|
2184
|
-
statusline=not args.no_statusline,
|
|
2185
|
-
bash_hook=not args.no_bash_hook,
|
|
2186
|
-
read_guard=not args.no_read_guard,
|
|
2187
|
-
model_defaults=not args.no_model_defaults,
|
|
2188
|
-
failed_attempt_nudge=(
|
|
2189
|
-
DEFAULT_FAILED_ATTEMPT_NUDGE
|
|
2190
|
-
if args.failed_attempt_nudge is None
|
|
2191
|
-
else args.failed_attempt_nudge
|
|
2192
|
-
),
|
|
2193
|
-
)
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
def render_text(result: SetupResult) -> str:
|
|
2197
|
-
mode = "applied" if result.applied else ("apply requested; no writes" if result.apply_requested else "plan only")
|
|
2198
|
-
lines = [
|
|
2199
|
-
f"ContextGuard setup ({mode})",
|
|
2200
|
-
f"scope={result.scope}",
|
|
2201
|
-
f"root={result.root}",
|
|
2202
|
-
f"settings={result.settings_path}",
|
|
2203
|
-
]
|
|
2204
|
-
if result.backup_path:
|
|
2205
|
-
lines.append(f"backup={result.backup_path}")
|
|
2206
|
-
if result.rollback_path:
|
|
2207
|
-
lines.append(f"rollback={result.rollback_path}")
|
|
2208
|
-
for warning in result.warnings or []:
|
|
2209
|
-
lines.append(f"warning={warning}")
|
|
2210
|
-
if result.diet_scan:
|
|
2211
|
-
scan = result.diet_scan
|
|
2212
|
-
lines.append("post-setup diet scan:")
|
|
2213
|
-
if scan.get("status") == "completed":
|
|
2214
|
-
counts = scan.get("severity_counts", {})
|
|
2215
|
-
lines.append(
|
|
2216
|
-
"- "
|
|
2217
|
-
f"findings={scan.get('finding_count', 0)} "
|
|
2218
|
-
f"high={counts.get('high', 0)} medium={counts.get('medium', 0)} low={counts.get('low', 0)}"
|
|
2219
|
-
)
|
|
2220
|
-
for finding in scan.get("top_findings", []):
|
|
2221
|
-
lines.append(f"- [{str(finding.get('severity', '')).upper()}] {finding.get('id')} @ {finding.get('path')}")
|
|
2222
|
-
else:
|
|
2223
|
-
lines.append(f"- skipped/failed: {scan.get('reason', scan.get('status', 'unknown'))}")
|
|
2224
|
-
lines.append("actions:")
|
|
2225
|
-
if result.actions:
|
|
2226
|
-
lines.extend(f"- {action}" for action in result.actions)
|
|
2227
|
-
else:
|
|
2228
|
-
lines.append("- no settings changes needed")
|
|
2229
|
-
# Only surface the cross-agent section when a non-Claude adapter is engaged,
|
|
2230
|
-
# keeping the default Claude-only text output unchanged.
|
|
2231
|
-
extra_adapters = [entry for entry in (result.adapter_plan or []) if entry.get("key") != "claude"]
|
|
2232
|
-
brief_adapters = [entry for entry in (result.adapter_plan or []) if entry.get("brief_mode")]
|
|
2233
|
-
if extra_adapters or brief_adapters:
|
|
2234
|
-
lines.append("cross-agent adapters:")
|
|
2235
|
-
for entry in result.adapter_plan or []:
|
|
2236
|
-
lines.append(f"- {entry['key']} [{entry['capability']}] status={entry['status']}")
|
|
2237
|
-
for action in entry.get("planned_actions", []):
|
|
2238
|
-
lines.append(f" - {action}")
|
|
2239
|
-
if entry.get("brief_mode_backup_path"):
|
|
2240
|
-
lines.append(f" - backup={entry['brief_mode_backup_path']}")
|
|
2241
|
-
if entry.get("rule_backup_path"):
|
|
2242
|
-
lines.append(f" - backup={entry['rule_backup_path']}")
|
|
2243
|
-
if result.apply_requested and not result.applied:
|
|
2244
|
-
lines.append("No supported writes were applied.")
|
|
2245
|
-
elif not result.applied:
|
|
2246
|
-
lines.append("Run with --yes to apply the selected plan non-interactively.")
|
|
2247
|
-
return "\n".join(lines) + "\n"
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
def run(args: argparse.Namespace) -> SetupResult:
|
|
2251
|
-
require_no_follow_file_ops_supported()
|
|
2252
|
-
scope = normalize_scope(getattr(args, "scope", "project"))
|
|
2253
|
-
root = resolve_scope_root(args.root, scope)
|
|
2254
|
-
settings_path = root / SETTINGS_REL
|
|
2255
|
-
warnings: list[str] = []
|
|
2256
|
-
if scope == "user":
|
|
2257
|
-
warnings.append(
|
|
2258
|
-
"user-scope setup can affect future projects; writes require --yes and explicit --agent/--only selection"
|
|
2259
|
-
)
|
|
2260
|
-
|
|
2261
|
-
# Cross-agent targets. Default keeps Claude compatibility (Claude is always
|
|
2262
|
-
# targeted plus any detected agent); --only narrows to an explicit set.
|
|
2263
|
-
selected_agents = explicit_agent_selection(args)
|
|
2264
|
-
targets = resolve_target_adapters(root, selected_agents)
|
|
2265
|
-
claude_targeted = any(adapter.key == "claude" for adapter in targets)
|
|
2266
|
-
|
|
2267
|
-
if claude_targeted:
|
|
2268
|
-
validate_settings_target(root, settings_path, allow_home_settings=(args.allow_home_settings or scope == "user"))
|
|
2269
|
-
original_text = _read_optional_text_no_follow(settings_path)
|
|
2270
|
-
original = _parse_json_object_text(original_text, settings_path)
|
|
2271
|
-
settings = json.loads(json.dumps(original))
|
|
2272
|
-
else:
|
|
2273
|
-
original_text = None
|
|
2274
|
-
original = {}
|
|
2275
|
-
settings = {}
|
|
2276
|
-
|
|
2277
|
-
choices = choices_from_args(args)
|
|
2278
|
-
interactive = (
|
|
2279
|
-
sys.stdin.isatty()
|
|
2280
|
-
and not args.yes
|
|
2281
|
-
and not args.plan
|
|
2282
|
-
and not args.dry_run
|
|
2283
|
-
and claude_targeted
|
|
2284
|
-
)
|
|
2285
|
-
if interactive:
|
|
2286
|
-
choices = interactive_choices(choices)
|
|
2287
|
-
|
|
2288
|
-
actions = apply_choices(settings, choices) if claude_targeted else []
|
|
2289
|
-
changed = (settings != original) if claude_targeted else False
|
|
2290
|
-
|
|
2291
|
-
apply_requested = bool(args.yes and not args.dry_run and not args.plan)
|
|
2292
|
-
if scope == "user" and apply_requested and not selected_agents:
|
|
2293
|
-
raise SystemExit(
|
|
2294
|
-
"Refusing user-scope writes without an explicit agent. "
|
|
2295
|
-
"Pass --agent claude (or another specific adapter) with --scope user."
|
|
2296
|
-
)
|
|
2297
|
-
if interactive and changed:
|
|
2298
|
-
preview = SetupResult(
|
|
2299
|
-
root=root,
|
|
2300
|
-
settings_path=settings_path,
|
|
2301
|
-
scope=scope,
|
|
2302
|
-
changed=changed,
|
|
2303
|
-
applied=False,
|
|
2304
|
-
apply_requested=False,
|
|
2305
|
-
choices=choices,
|
|
2306
|
-
actions=actions,
|
|
2307
|
-
warnings=warnings,
|
|
2308
|
-
)
|
|
2309
|
-
print("\n" + render_text(preview))
|
|
2310
|
-
prompt_scope = "user-level" if scope == "user" else "project-local"
|
|
2311
|
-
apply_requested = prompt_bool(f"Apply these {prompt_scope} changes now?", True)
|
|
2312
|
-
if scope == "user" and apply_requested and not selected_agents:
|
|
2313
|
-
raise SystemExit(
|
|
2314
|
-
"Refusing user-scope writes without an explicit agent. "
|
|
2315
|
-
"Pass --agent claude (or another specific adapter) with --scope user."
|
|
2316
|
-
)
|
|
2317
|
-
|
|
2318
|
-
backup_path = None
|
|
2319
|
-
rollback_id = None
|
|
2320
|
-
rollback_path = None
|
|
2321
|
-
claude_settings_written = False
|
|
2322
|
-
if claude_targeted and apply_requested and changed:
|
|
2323
|
-
if scope == "user" and original_text is not None and args.no_backup:
|
|
2324
|
-
raise SystemExit("Refusing --no-backup for user-scope changes to existing Claude settings.")
|
|
2325
|
-
lock_fd = acquire_settings_lock(settings_path)
|
|
2326
|
-
try:
|
|
2327
|
-
current_text = _read_optional_text_no_follow(settings_path)
|
|
2328
|
-
if current_text != original_text:
|
|
2329
|
-
raise SystemExit(
|
|
2330
|
-
f"Settings changed while setup was preparing changes; re-run setup to merge latest file: {settings_path}"
|
|
2331
|
-
)
|
|
2332
|
-
if original_text is not None and not args.no_backup and settings != original:
|
|
2333
|
-
backup_path = backup_existing(settings_path)
|
|
2334
|
-
if settings != original:
|
|
2335
|
-
rollback_id, rollback_path = write_rollback_record(
|
|
2336
|
-
root=root,
|
|
2337
|
-
scope=scope,
|
|
2338
|
-
settings_path=settings_path,
|
|
2339
|
-
backup_path=backup_path,
|
|
2340
|
-
original_existed=(original_text is not None),
|
|
2341
|
-
)
|
|
2342
|
-
atomic_write(
|
|
2343
|
-
settings_path,
|
|
2344
|
-
json.dumps(settings, indent=2, sort_keys=True) + "\n",
|
|
2345
|
-
existing_mode_or_default(settings_path, 0o600),
|
|
2346
|
-
)
|
|
2347
|
-
claude_settings_written = True
|
|
2348
|
-
finally:
|
|
2349
|
-
release_settings_lock(lock_fd)
|
|
2350
|
-
|
|
2351
|
-
# Build the per-adapter plan; repo-rule writes happen here when an applying
|
|
2352
|
-
# run (--yes) requested --with-init or project-scope --brief-mode.
|
|
2353
|
-
adapter_plan = build_adapter_plan(
|
|
2354
|
-
root,
|
|
2355
|
-
targets,
|
|
2356
|
-
scope=scope,
|
|
2357
|
-
claude_actions=actions,
|
|
2358
|
-
claude_changed=changed,
|
|
2359
|
-
claude_applied=(claude_targeted and apply_requested),
|
|
2360
|
-
with_init=bool(getattr(args, "with_init", False)),
|
|
2361
|
-
with_skill=bool(getattr(args, "with_skill", False)),
|
|
2362
|
-
applied=apply_requested,
|
|
2363
|
-
brief_mode=getattr(args, "brief_mode", None),
|
|
2364
|
-
)
|
|
2365
|
-
# Surface any repo-rule writes in the top-level actions for visibility. Claude
|
|
2366
|
-
# actions are already in ``actions``; only adapter-side writes are appended.
|
|
2367
|
-
for entry in adapter_plan:
|
|
2368
|
-
actions.extend(entry.get("applied_actions", []))
|
|
2369
|
-
adapter_writes = any(entry.get("applied_actions") for entry in adapter_plan)
|
|
2370
|
-
applied = bool(claude_settings_written or adapter_writes)
|
|
2371
|
-
|
|
2372
|
-
diet_scan = None
|
|
2373
|
-
if (applied or (apply_requested and claude_targeted)) and not getattr(args, "no_diet_scan", False):
|
|
2374
|
-
diet_scan = run_post_setup_diet_scan(root)
|
|
2375
|
-
|
|
2376
|
-
return SetupResult(
|
|
2377
|
-
root=root,
|
|
2378
|
-
settings_path=settings_path,
|
|
2379
|
-
scope=scope,
|
|
2380
|
-
changed=changed,
|
|
2381
|
-
applied=applied,
|
|
2382
|
-
apply_requested=apply_requested,
|
|
2383
|
-
choices=choices,
|
|
2384
|
-
actions=actions,
|
|
2385
|
-
backup_path=backup_path,
|
|
2386
|
-
rollback_id=rollback_id,
|
|
2387
|
-
rollback_path=rollback_path,
|
|
2388
|
-
warnings=warnings,
|
|
2389
|
-
diet_scan=diet_scan,
|
|
2390
|
-
adapter_plan=adapter_plan,
|
|
2391
|
-
)
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
def build_parser() -> argparse.ArgumentParser:
|
|
2395
|
-
parser = argparse.ArgumentParser(description="Interactively configure ContextGuard project settings.")
|
|
2396
|
-
parser.add_argument("--root", default=None, help="project root to configure (default: nearest git root, else current directory)")
|
|
2397
|
-
parser.add_argument(
|
|
2398
|
-
"--scope",
|
|
2399
|
-
choices=("project", "user", "global"),
|
|
2400
|
-
default="project",
|
|
2401
|
-
help="setup scope: project-local by default; user/global targets only known user-level paths and requires explicit --agent for writes",
|
|
2402
|
-
)
|
|
2403
|
-
parser.add_argument(
|
|
2404
|
-
"--allow-home-settings",
|
|
2405
|
-
action="store_true",
|
|
2406
|
-
help="deprecated compatibility alias for user-level Claude settings; prefer --scope user --agent claude",
|
|
2407
|
-
)
|
|
2408
|
-
parser.add_argument("--yes", action="store_true", help="apply the recommended/selected setup without prompts")
|
|
2409
|
-
parser.add_argument("--plan", action="store_true", help="show the setup plan without writing files")
|
|
2410
|
-
parser.add_argument("--dry-run", action="store_true", help="alias for --plan")
|
|
2411
|
-
parser.add_argument("--verify", action="store_true", help="run a read-only setup health check; never writes or prompts")
|
|
2412
|
-
parser.add_argument("--json", action="store_true", help="print machine-readable result")
|
|
2413
|
-
parser.add_argument("--no-backup", action="store_true", help="do not create .bak-* before modifying existing settings")
|
|
2414
|
-
parser.add_argument("--no-denies", action="store_true", help="skip recommended permissions.deny rules")
|
|
2415
|
-
parser.add_argument("--no-statusline", action="store_true", help="skip token statusline")
|
|
2416
|
-
parser.add_argument("--no-bash-hook", action="store_true", help="skip Bash trim/sanitize hook")
|
|
2417
|
-
parser.add_argument("--no-read-guard", action="store_true", help="skip large Read guard hook")
|
|
2418
|
-
parser.add_argument("--no-model-defaults", action="store_true", help="skip model/effort defaults")
|
|
2419
|
-
parser.add_argument("--no-diet-scan", action="store_true", help="skip the read-only diet scan summary after applying setup")
|
|
2420
|
-
parser.add_argument(
|
|
2421
|
-
"--agent",
|
|
2422
|
-
action="append",
|
|
2423
|
-
default=None,
|
|
2424
|
-
metavar="ADAPTER",
|
|
2425
|
-
help="adapter key(s) to configure; comma-separated or repeatable. Alias for --only.",
|
|
2426
|
-
)
|
|
2427
|
-
parser.add_argument(
|
|
2428
|
-
"--only",
|
|
2429
|
-
action="append",
|
|
2430
|
-
default=None,
|
|
2431
|
-
metavar="ADAPTER",
|
|
2432
|
-
help="restrict cross-agent setup/plan to adapter key(s); comma-separated or repeatable "
|
|
2433
|
-
"(e.g. --only codex,gemini). Default: claude plus any detected agents.",
|
|
2434
|
-
)
|
|
2435
|
-
parser.add_argument(
|
|
2436
|
-
"--with-init",
|
|
2437
|
-
dest="with_init",
|
|
2438
|
-
action="store_true",
|
|
2439
|
-
help="also write advisory ContextGuard rule files for repo-rule agents (AGENTS.md, GEMINI.md, .cursorrules, etc.) "
|
|
2440
|
-
"when applying; safe and idempotent.",
|
|
2441
|
-
)
|
|
2442
|
-
parser.add_argument(
|
|
2443
|
-
"--with-skill",
|
|
2444
|
-
dest="with_skill",
|
|
2445
|
-
action="store_true",
|
|
2446
|
-
help="also generate optional project-local skill files where supported, currently Codex .agents/skills/context-guard/SKILL.md.",
|
|
2447
|
-
)
|
|
2448
|
-
parser.add_argument(
|
|
2449
|
-
"--brief-mode",
|
|
2450
|
-
choices=BRIEF_MODE_CHOICES,
|
|
2451
|
-
default=None,
|
|
2452
|
-
help="plan/apply advisory brief-mode snippets in project rule files; choose lite, standard, ultra, or off to remove.",
|
|
2453
|
-
)
|
|
2454
|
-
parser.add_argument(
|
|
2455
|
-
"--list-adapters",
|
|
2456
|
-
dest="list_adapters",
|
|
2457
|
-
action="store_true",
|
|
2458
|
-
help="print the cross-agent adapter registry and exit",
|
|
2459
|
-
)
|
|
2460
|
-
nudge_group = parser.add_mutually_exclusive_group()
|
|
2461
|
-
nudge_group.add_argument(
|
|
2462
|
-
"--failed-attempt-nudge",
|
|
2463
|
-
dest="failed_attempt_nudge",
|
|
2464
|
-
action="store_true",
|
|
2465
|
-
default=None,
|
|
2466
|
-
help="enable PostToolUse Bash hook that suggests /clear when the same command fails twice in a row (recommended default)",
|
|
2467
|
-
)
|
|
2468
|
-
nudge_group.add_argument(
|
|
2469
|
-
"--no-failed-attempt-nudge",
|
|
2470
|
-
dest="failed_attempt_nudge",
|
|
2471
|
-
action="store_false",
|
|
2472
|
-
default=None,
|
|
2473
|
-
help="skip the failed-attempt /clear nudge hook",
|
|
2474
|
-
)
|
|
2475
|
-
return parser
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
def main() -> int:
|
|
2479
|
-
parser = build_parser()
|
|
2480
|
-
args = parser.parse_args()
|
|
2481
|
-
if args.dry_run:
|
|
2482
|
-
args.plan = True
|
|
2483
|
-
if args.verify and args.yes:
|
|
2484
|
-
parser.error("--verify is read-only and cannot be combined with --yes")
|
|
2485
|
-
if getattr(args, "list_adapters", False):
|
|
2486
|
-
payload = adapter_registry_payload()
|
|
2487
|
-
if args.json:
|
|
2488
|
-
print(json.dumps({"adapters": payload}, indent=2, sort_keys=True))
|
|
2489
|
-
else:
|
|
2490
|
-
print("ContextGuard cross-agent adapters:")
|
|
2491
|
-
for item in payload:
|
|
2492
|
-
print(f"- {item['key']} [{item['capability']}] {item['display_name']}: {item['summary']}")
|
|
2493
|
-
return 0
|
|
2494
|
-
if args.verify:
|
|
2495
|
-
args.plan = True
|
|
2496
|
-
result = run_doctor(args)
|
|
2497
|
-
if args.json:
|
|
2498
|
-
print(json.dumps(result, indent=2, sort_keys=True))
|
|
2499
|
-
else:
|
|
2500
|
-
print(render_doctor_text(result))
|
|
2501
|
-
return 0
|
|
2502
|
-
# Safety default for non-interactive Claude Code Bash calls: do not write
|
|
2503
|
-
# unless --yes is explicit.
|
|
2504
|
-
if not sys.stdin.isatty() and not args.yes:
|
|
2505
|
-
args.plan = True
|
|
2506
|
-
result = run(args)
|
|
2507
|
-
if args.json:
|
|
2508
|
-
print(json.dumps(result.as_dict(), indent=2, sort_keys=True))
|
|
2509
|
-
else:
|
|
2510
|
-
print(render_text(result))
|
|
2511
|
-
return 0
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
if __name__ == "__main__":
|
|
2515
|
-
raise SystemExit(main())
|