context-guard-cli 2.1.0__py3-none-any.whl
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.
- context_guard/__init__.py +1 -0
- context_guard/_data/hosts/antigravity/hooks.snippet.json +16 -0
- context_guard/_data/hosts/antigravity/rules/context-guard.md +15 -0
- context_guard/_data/hosts/claude-code/commands/cg-continue.md +13 -0
- context_guard/_data/hosts/claude-code/commands/cg-new.md +11 -0
- context_guard/_data/hosts/claude-code/mcp.snippet.json +7 -0
- context_guard/_data/hosts/claude-code/settings.snippet.json +12 -0
- context_guard/_data/hosts/opencode/agent.snippet.json +7 -0
- context_guard/_data/hosts/opencode/commands/cg-continue.md +16 -0
- context_guard/_data/hosts/opencode/commands/cg-new.md +12 -0
- context_guard/_data/hosts/opencode/mcp.snippet.json +9 -0
- context_guard/_data/hosts/opencode/permissions.snippet.json +12 -0
- context_guard/_data/phases/execute.md +99 -0
- context_guard/_data/phases/plan.md +136 -0
- context_guard/_data/phases/verify.md +129 -0
- context_guard/guard/__init__.py +1 -0
- context_guard/guard/assets.py +94 -0
- context_guard/guard/cli.py +307 -0
- context_guard/guard/commands.py +811 -0
- context_guard/guard/errors.py +71 -0
- context_guard/guard/locking.py +181 -0
- context_guard/guard/manifest.py +69 -0
- context_guard/guard/migrate.py +288 -0
- context_guard/guard/paths.py +199 -0
- context_guard/guard/setup.py +476 -0
- context_guard/guard/transaction.py +403 -0
- context_guard/mcp_server.py +280 -0
- context_guard_cli-2.1.0.dist-info/METADATA +296 -0
- context_guard_cli-2.1.0.dist-info/RECORD +32 -0
- context_guard_cli-2.1.0.dist-info/WHEEL +4 -0
- context_guard_cli-2.1.0.dist-info/entry_points.txt +4 -0
- context_guard_cli-2.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Path resolution and agent identity for guard middleware."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .errors import (
|
|
9
|
+
AmbiguousChangeError,
|
|
10
|
+
CommandResult,
|
|
11
|
+
EXIT_GENERIC,
|
|
12
|
+
LegacyLayoutError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Constantes
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
MAX_ARTIFACT_CHARS = 6000 # ~1500 tokens, cap de longitud para artefactos
|
|
20
|
+
|
|
21
|
+
TASK_LINE_RE = re.compile(r"^\s*-\s*\[( |x|X|/)\]\s*(.*)$")
|
|
22
|
+
|
|
23
|
+
BASE_DIRNAME = ".context-guard"
|
|
24
|
+
CHANGES_DIRNAME = "changes"
|
|
25
|
+
ARCHIVE_DIRNAME = "archive"
|
|
26
|
+
|
|
27
|
+
# Name used when a context has no changes yet, and the destination of a
|
|
28
|
+
# migrated context-guard 1.x flat layout.
|
|
29
|
+
DEFAULT_CHANGE = "default"
|
|
30
|
+
|
|
31
|
+
# A change name becomes a directory name, so it must not be able to escape the
|
|
32
|
+
# changes directory or collide with the archive.
|
|
33
|
+
CHANGE_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Rutas — absolutas, ancladas al directorio del proyecto (context)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def get_root(context):
|
|
41
|
+
"""Directorio del proyecto, normalizado a ruta absoluta."""
|
|
42
|
+
return os.path.abspath(context)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_base(context):
|
|
46
|
+
"""Directorio raíz de context-guard dentro del proyecto."""
|
|
47
|
+
return os.path.join(get_root(context), BASE_DIRNAME)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_changes_dir(context):
|
|
51
|
+
"""Directorio que contiene un subdirectorio por change."""
|
|
52
|
+
return os.path.join(get_base(context), CHANGES_DIRNAME)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_archive_dir(context):
|
|
56
|
+
"""Destino de los changes completados."""
|
|
57
|
+
return os.path.join(get_changes_dir(context), ARCHIVE_DIRNAME)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_change_name(name):
|
|
61
|
+
"""Rechaza nombres que no pueden ser un directorio seguro.
|
|
62
|
+
|
|
63
|
+
A change name is used verbatim as a directory name, so path traversal and
|
|
64
|
+
collisions with the archive directory are rejected here rather than
|
|
65
|
+
discovered later as a corrupted layout.
|
|
66
|
+
"""
|
|
67
|
+
if not name or not CHANGE_NAME_RE.match(name):
|
|
68
|
+
raise AmbiguousChangeError(
|
|
69
|
+
f"FAIL|INVALID_CHANGE_NAME|{name}|"
|
|
70
|
+
"must start alphanumeric and contain only [A-Za-z0-9._-]"
|
|
71
|
+
)
|
|
72
|
+
if name == ARCHIVE_DIRNAME:
|
|
73
|
+
raise AmbiguousChangeError(
|
|
74
|
+
f"FAIL|INVALID_CHANGE_NAME|{name}|reserved for archived changes"
|
|
75
|
+
)
|
|
76
|
+
return name
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_changes(context):
|
|
80
|
+
"""Nombres de los changes activos, ordenados.
|
|
81
|
+
|
|
82
|
+
The ordering is for display only. It must never be used to pick a change
|
|
83
|
+
implicitly — see resolve_change.
|
|
84
|
+
"""
|
|
85
|
+
changes_dir = get_changes_dir(context)
|
|
86
|
+
if not os.path.isdir(changes_dir):
|
|
87
|
+
return []
|
|
88
|
+
names = []
|
|
89
|
+
for entry in sorted(os.listdir(changes_dir)):
|
|
90
|
+
if entry == ARCHIVE_DIRNAME:
|
|
91
|
+
continue
|
|
92
|
+
if os.path.isdir(os.path.join(changes_dir, entry)):
|
|
93
|
+
names.append(entry)
|
|
94
|
+
return names
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_legacy_flat_layout(context):
|
|
98
|
+
"""True si el contexto usa el layout plano de context-guard 1.x.
|
|
99
|
+
|
|
100
|
+
A 1.x context keeps manifest.json directly under .context-guard/ with no
|
|
101
|
+
changes/ directory. Treating that as "no changes yet" would silently start
|
|
102
|
+
an empty session and make the user's existing work look like it vanished.
|
|
103
|
+
"""
|
|
104
|
+
base = get_base(context)
|
|
105
|
+
if os.path.isdir(get_changes_dir(context)):
|
|
106
|
+
return False
|
|
107
|
+
return os.path.exists(os.path.join(base, "manifest.json"))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_change(context, change=None):
|
|
111
|
+
"""Determina sobre qué change opera un comando.
|
|
112
|
+
|
|
113
|
+
Rules, in order:
|
|
114
|
+
- an explicit name is always honoured;
|
|
115
|
+
- a legacy 1.x layout is an error telling the user to migrate, never a
|
|
116
|
+
silent fresh start;
|
|
117
|
+
- exactly one active change is used implicitly;
|
|
118
|
+
- several active changes is an error naming them all.
|
|
119
|
+
|
|
120
|
+
That last rule is the point of this function. state-guard resolved
|
|
121
|
+
ambiguity by taking the alphabetically first change, so an agent working
|
|
122
|
+
on "zebra" silently operated on "alpha" and nothing ever said so.
|
|
123
|
+
"""
|
|
124
|
+
if change:
|
|
125
|
+
return validate_change_name(change)
|
|
126
|
+
|
|
127
|
+
if is_legacy_flat_layout(context):
|
|
128
|
+
raise LegacyLayoutError(get_base(context))
|
|
129
|
+
|
|
130
|
+
names = list_changes(context)
|
|
131
|
+
if not names:
|
|
132
|
+
return DEFAULT_CHANGE
|
|
133
|
+
if len(names) == 1:
|
|
134
|
+
return names[0]
|
|
135
|
+
raise AmbiguousChangeError(
|
|
136
|
+
"FAIL|AMBIGUOUS_CHANGE|" + ",".join(names) +
|
|
137
|
+
"|pass --change to say which one"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def missing_session_result(context, change=None):
|
|
142
|
+
"""What to report when a command finds no manifest to operate on.
|
|
143
|
+
|
|
144
|
+
Two different failures used to share one message. A caller who named a
|
|
145
|
+
change and mistyped it got FAIL|NO_SESSION, which reads as "this project
|
|
146
|
+
has no session" and sends them looking for a broken project instead of at
|
|
147
|
+
the name they just typed. That lands hardest at the approval gate, the one
|
|
148
|
+
place a human types a change name by hand.
|
|
149
|
+
|
|
150
|
+
Deliberately not raised from resolve_change: `cg new` resolves a name that
|
|
151
|
+
does not exist yet, by definition.
|
|
152
|
+
"""
|
|
153
|
+
if change:
|
|
154
|
+
available = list_changes(context)
|
|
155
|
+
listed = ", ".join(available) if available else "(none)"
|
|
156
|
+
return CommandResult(
|
|
157
|
+
f"FAIL|CHANGE_NOT_FOUND|{change}|available: {listed}",
|
|
158
|
+
EXIT_GENERIC,
|
|
159
|
+
)
|
|
160
|
+
return CommandResult("FAIL|NO_SESSION", EXIT_GENERIC)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_paths(context, change=None):
|
|
164
|
+
"""Rutas de sesión ancladas a un change dentro del proyecto.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
context: Ruta absoluta al directorio del proyecto. Se normaliza
|
|
168
|
+
con os.path.abspath() para garantizar rutas absolutas.
|
|
169
|
+
change: Nombre del change. Si es None se resuelve con resolve_change.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
dict con rutas absolutas: base, manifest, tasks, lock, write_lock,
|
|
173
|
+
archive, change.
|
|
174
|
+
"""
|
|
175
|
+
name = resolve_change(context, change)
|
|
176
|
+
base = os.path.join(get_changes_dir(context), name)
|
|
177
|
+
return {
|
|
178
|
+
"base": base,
|
|
179
|
+
"manifest": os.path.join(base, "manifest.json"),
|
|
180
|
+
"tasks": os.path.join(base, "tasks.md"),
|
|
181
|
+
"lock": os.path.join(base, ".lock"),
|
|
182
|
+
"write_lock": os.path.join(base, ".write.lock"),
|
|
183
|
+
"archive": get_archive_dir(context),
|
|
184
|
+
"change": name,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ---------------------------------------------------------------------------
|
|
189
|
+
# Identidad del agente
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
def generate_agent_id():
|
|
193
|
+
"""Identidad consistente para locks de sesión y de tarea.
|
|
194
|
+
|
|
195
|
+
Incluye PID + hostname + timestamp para unicidad global.
|
|
196
|
+
Para ownership tracking entre claim/release, usar el agent_id
|
|
197
|
+
retornado por claim y pasarlo explícitamente a release.
|
|
198
|
+
"""
|
|
199
|
+
return f"{os.getpid()}-{socket.gethostname()}-{int(time.time())}"
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""`cg setup` — install the host adapters (PLAN-2.1 F2).
|
|
2
|
+
|
|
3
|
+
Replaces the shell installer that 2.0 shipped under adapters/. Two things
|
|
4
|
+
changed beyond the language:
|
|
5
|
+
|
|
6
|
+
- **Scope.** 2.0 installed per-project by default, which meant visiting every
|
|
7
|
+
project. The default here is global — one command per machine — with
|
|
8
|
+
`--project` keeping the old mode for teams that want the config committed.
|
|
9
|
+
- **Source.** The artifacts come from the package's embedded data, not from a
|
|
10
|
+
clone of the repository sitting next to the target.
|
|
11
|
+
|
|
12
|
+
Every merge preserves configuration the user already had and is idempotent by
|
|
13
|
+
construction: existing keys are never replaced, list entries are appended only
|
|
14
|
+
when absent, and output is always written with the same formatting, so a
|
|
15
|
+
second run produces byte-identical files.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
|
|
21
|
+
from .assets import iter_host_files, read_snippet
|
|
22
|
+
from .errors import CommandResult, EXIT_OK, EXIT_VALIDATION, GuardError
|
|
23
|
+
|
|
24
|
+
# CLI host names map to the packaged directory names, which are not identical:
|
|
25
|
+
# the host is called "claude", its adapter directory "claude-code".
|
|
26
|
+
HOST_DIRS = {
|
|
27
|
+
"claude": "claude-code",
|
|
28
|
+
"opencode": "opencode",
|
|
29
|
+
"antigravity": "antigravity",
|
|
30
|
+
}
|
|
31
|
+
VALID_HOSTS = tuple(HOST_DIRS) + ("all",)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConfigCorruptError(GuardError):
|
|
35
|
+
"""An existing config file could not be parsed.
|
|
36
|
+
|
|
37
|
+
The shell installer caught this and carried on with an empty dict, which
|
|
38
|
+
silently overwrote whatever the user had — the bug that made a stray
|
|
39
|
+
"//" in a URL destroy a config file. Refusing is the only safe move: this
|
|
40
|
+
code cannot tell a file it fails to parse from one it would destroy.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, path):
|
|
44
|
+
super().__init__(
|
|
45
|
+
f"FAIL|CONFIG_UNPARSEABLE|{path}|refusing to overwrite it — "
|
|
46
|
+
"fix or move the file and re-run",
|
|
47
|
+
EXIT_VALIDATION,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _home():
|
|
52
|
+
return os.path.expanduser("~")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _strip_jsonc_comments(raw):
|
|
56
|
+
"""Remove // and /* */ comments, ignoring anything inside a string.
|
|
57
|
+
|
|
58
|
+
A regex cannot do this correctly and the project has now been bitten
|
|
59
|
+
twice proving it. First `//.*` ate the rest of the line starting at the
|
|
60
|
+
"//" in "https://opencode.ai/config.json"; the negative lookbehind added
|
|
61
|
+
to fix that left `/\\*.*?\\*/` free to eat the "/**/" in the glob
|
|
62
|
+
".context-guard/**/manifest.json" — a pattern this installer writes
|
|
63
|
+
itself, so the corruption appeared on the second run and broke
|
|
64
|
+
idempotency.
|
|
65
|
+
|
|
66
|
+
Tracking string state is the only thing that distinguishes a comment from
|
|
67
|
+
a comment-shaped substring of a value, so that is what this does.
|
|
68
|
+
"""
|
|
69
|
+
out = []
|
|
70
|
+
i, n = 0, len(raw)
|
|
71
|
+
in_string = False
|
|
72
|
+
while i < n:
|
|
73
|
+
ch = raw[i]
|
|
74
|
+
if in_string:
|
|
75
|
+
out.append(ch)
|
|
76
|
+
if ch == "\\" and i + 1 < n:
|
|
77
|
+
out.append(raw[i + 1])
|
|
78
|
+
i += 2
|
|
79
|
+
continue
|
|
80
|
+
if ch == '"':
|
|
81
|
+
in_string = False
|
|
82
|
+
i += 1
|
|
83
|
+
continue
|
|
84
|
+
if ch == '"':
|
|
85
|
+
in_string = True
|
|
86
|
+
out.append(ch)
|
|
87
|
+
i += 1
|
|
88
|
+
continue
|
|
89
|
+
if raw.startswith("//", i):
|
|
90
|
+
end = raw.find("\n", i)
|
|
91
|
+
i = n if end == -1 else end
|
|
92
|
+
continue
|
|
93
|
+
if raw.startswith("/*", i):
|
|
94
|
+
end = raw.find("*/", i + 2)
|
|
95
|
+
i = n if end == -1 else end + 2
|
|
96
|
+
continue
|
|
97
|
+
out.append(ch)
|
|
98
|
+
i += 1
|
|
99
|
+
return "".join(out)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _read_json(path, jsonc=False):
|
|
103
|
+
"""Existing config, or None when there is no file yet."""
|
|
104
|
+
if not os.path.exists(path):
|
|
105
|
+
return None
|
|
106
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
107
|
+
raw = f.read()
|
|
108
|
+
if jsonc:
|
|
109
|
+
raw = _strip_jsonc_comments(raw)
|
|
110
|
+
try:
|
|
111
|
+
return json.loads(raw)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
raise ConfigCorruptError(path)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_json(path, cfg):
|
|
117
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
118
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
119
|
+
json.dump(cfg, f, indent=2)
|
|
120
|
+
f.write("\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_text(path, text):
|
|
124
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
125
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
126
|
+
f.write(text)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _append_missing(target, entries):
|
|
130
|
+
for entry in entries:
|
|
131
|
+
if entry not in target:
|
|
132
|
+
target.append(entry)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
# Per-host installers. Each returns the list of paths it touched, relative to
|
|
137
|
+
# the scope root, so the caller can print exactly what changed.
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def _install_claude(root, with_mcp, global_scope):
|
|
141
|
+
touched = []
|
|
142
|
+
|
|
143
|
+
for relpath, text in iter_host_files("claude-code"):
|
|
144
|
+
if not relpath.startswith("commands/"):
|
|
145
|
+
continue
|
|
146
|
+
name = relpath.split("/", 1)[1]
|
|
147
|
+
_write_text(os.path.join(root, ".claude", "commands", name), text)
|
|
148
|
+
touched.append(f".claude/commands/{name}")
|
|
149
|
+
|
|
150
|
+
settings_path = os.path.join(root, ".claude", "settings.json")
|
|
151
|
+
snippet = json.loads(read_snippet("claude-code", "settings"))
|
|
152
|
+
cfg = _read_json(settings_path) or {}
|
|
153
|
+
perms = cfg.setdefault("permissions", {})
|
|
154
|
+
_append_missing(perms.setdefault("ask", []), snippet["permissions"]["ask"])
|
|
155
|
+
_append_missing(perms.setdefault("deny", []), snippet["permissions"].get("deny", []))
|
|
156
|
+
_write_json(settings_path, cfg)
|
|
157
|
+
touched.append(".claude/settings.json")
|
|
158
|
+
|
|
159
|
+
if with_mcp:
|
|
160
|
+
# User scope lives in ~/.claude.json; a project keeps its servers in
|
|
161
|
+
# .mcp.json, which is the file Claude Code reads per repository.
|
|
162
|
+
# Keyed off the scope rather than off comparing root to $HOME, which
|
|
163
|
+
# would pick the wrong file for `--project ~`.
|
|
164
|
+
mcp_rel = ".claude.json" if global_scope else ".mcp.json"
|
|
165
|
+
mcp_path = os.path.join(root, mcp_rel)
|
|
166
|
+
snippet = json.loads(read_snippet("claude-code", "mcp"))
|
|
167
|
+
cfg = _read_json(mcp_path) or {}
|
|
168
|
+
cfg.setdefault("mcpServers", {}).setdefault(
|
|
169
|
+
"context-guard", snippet["mcpServers"]["context-guard"])
|
|
170
|
+
_write_json(mcp_path, cfg)
|
|
171
|
+
touched.append(mcp_rel)
|
|
172
|
+
|
|
173
|
+
return touched
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _install_opencode(root, with_mcp, global_scope):
|
|
177
|
+
touched = []
|
|
178
|
+
|
|
179
|
+
commands_dir = (os.path.join(".config", "opencode", "commands")
|
|
180
|
+
if global_scope else os.path.join(".opencode", "commands"))
|
|
181
|
+
for relpath, text in iter_host_files("opencode"):
|
|
182
|
+
if not relpath.startswith("commands/"):
|
|
183
|
+
continue
|
|
184
|
+
name = relpath.split("/", 1)[1]
|
|
185
|
+
_write_text(os.path.join(root, commands_dir, name), text)
|
|
186
|
+
touched.append(f"{commands_dir}/{name}".replace(os.sep, "/"))
|
|
187
|
+
|
|
188
|
+
cfg_rel = (os.path.join(".config", "opencode", "opencode.json")
|
|
189
|
+
if global_scope else "opencode.json")
|
|
190
|
+
cfg_path = os.path.join(root, cfg_rel)
|
|
191
|
+
cfg = _read_json(cfg_path, jsonc=True)
|
|
192
|
+
if cfg is None:
|
|
193
|
+
cfg = {"$schema": "https://opencode.ai/config.json", "agent": {}}
|
|
194
|
+
|
|
195
|
+
cfg.setdefault("agent", {}).update(json.loads(read_snippet("opencode", "agent")))
|
|
196
|
+
|
|
197
|
+
# Top-level "permission", not nested in the agent entry, so it applies
|
|
198
|
+
# regardless of which agent runs the command. Merged per-pattern so a rule
|
|
199
|
+
# the user set for a pattern we do not know about survives untouched.
|
|
200
|
+
permissions = json.loads(read_snippet("opencode", "permissions"))
|
|
201
|
+
perm_cfg = cfg.setdefault("permission", {})
|
|
202
|
+
for category, rules in permissions.get("permission", {}).items():
|
|
203
|
+
category_cfg = perm_cfg.setdefault(category, {})
|
|
204
|
+
for pattern, mode in rules.items():
|
|
205
|
+
category_cfg.setdefault(pattern, mode)
|
|
206
|
+
|
|
207
|
+
if with_mcp:
|
|
208
|
+
snippet = json.loads(read_snippet("opencode", "mcp"))
|
|
209
|
+
cfg.setdefault("mcp", {}).setdefault(
|
|
210
|
+
"context-guard", snippet["mcp"]["context-guard"])
|
|
211
|
+
|
|
212
|
+
_write_json(cfg_path, cfg)
|
|
213
|
+
touched.append(cfg_rel.replace(os.sep, "/"))
|
|
214
|
+
return touched
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
HOOKS_MANUAL_FIX = ("left untouched; add the deny hook manually "
|
|
218
|
+
"(see docs/adapters/antigravity/PERMISSIONS.md)")
|
|
219
|
+
|
|
220
|
+
# Annotated in the touched list because installing the hook is the one thing
|
|
221
|
+
# `cg setup` does that takes something away from the agent. Consented is not
|
|
222
|
+
# the same as silent: the line has to say what it is and how to decline it.
|
|
223
|
+
HOOKS_NOTE = "(deny hook for cg approve — skip with --no-hooks)"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _hooks_shape_problem(cfg):
|
|
227
|
+
"""Why this config cannot be merged into, or None if it can.
|
|
228
|
+
|
|
229
|
+
The merge used to assume the shape and reach straight for
|
|
230
|
+
`.setdefault("hooks", {}).setdefault("PreToolUse", [])`. Against a
|
|
231
|
+
`{"hooks": [...]}` it raised AttributeError and printed a traceback at a
|
|
232
|
+
user holding a perfectly ordinary file we simply did not expect.
|
|
233
|
+
|
|
234
|
+
Absent keys are fine: `{}` is what a fresh config looks like, and a check
|
|
235
|
+
strict enough to reject it would break the common case for the rare one.
|
|
236
|
+
Only a key present with the wrong type is a problem.
|
|
237
|
+
"""
|
|
238
|
+
if not isinstance(cfg, dict):
|
|
239
|
+
return "the root of the file is not a JSON object"
|
|
240
|
+
hooks = cfg.get("hooks")
|
|
241
|
+
if hooks is None:
|
|
242
|
+
return None
|
|
243
|
+
if not isinstance(hooks, dict):
|
|
244
|
+
return '"hooks" is not a JSON object'
|
|
245
|
+
pre_tool_use = hooks.get("PreToolUse")
|
|
246
|
+
if pre_tool_use is not None and not isinstance(pre_tool_use, list):
|
|
247
|
+
return '"hooks.PreToolUse" is not a list'
|
|
248
|
+
return None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _install_antigravity(root, global_scope, no_hooks=False):
|
|
252
|
+
"""Global scope installs the deny hook; project scope installs the rule.
|
|
253
|
+
|
|
254
|
+
The split follows what each artifact is for. The hook lives in user
|
|
255
|
+
config by definition, so it has no meaning in a project install; the
|
|
256
|
+
workspace rule travels with the repository, and for the global case
|
|
257
|
+
`cg new` materialises it per project instead.
|
|
258
|
+
|
|
259
|
+
Returns `(touched, failure)`. Unlike the other hosts this one merges into
|
|
260
|
+
a file it did not create and cannot fully predict, so it needs a way to
|
|
261
|
+
decline without taking the whole run down with it.
|
|
262
|
+
"""
|
|
263
|
+
if not global_scope:
|
|
264
|
+
for relpath, text in iter_host_files("antigravity"):
|
|
265
|
+
if relpath != "rules/context-guard.md":
|
|
266
|
+
continue
|
|
267
|
+
_write_text(os.path.join(root, ".agents", "rules", "context-guard.md"), text)
|
|
268
|
+
return [".agents/rules/context-guard.md"], None
|
|
269
|
+
return [], None
|
|
270
|
+
|
|
271
|
+
if no_hooks:
|
|
272
|
+
return [], None
|
|
273
|
+
|
|
274
|
+
hooks_rel = os.path.join(".gemini", "config", "hooks.json")
|
|
275
|
+
hooks_path = os.path.join(root, hooks_rel)
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
cfg = _read_json(hooks_path)
|
|
279
|
+
except ConfigCorruptError:
|
|
280
|
+
return [], f"FAIL|HOOKS_UNPARSEABLE|{hooks_path}|{HOOKS_MANUAL_FIX}"
|
|
281
|
+
if cfg is None:
|
|
282
|
+
cfg = {}
|
|
283
|
+
|
|
284
|
+
problem = _hooks_shape_problem(cfg)
|
|
285
|
+
if problem:
|
|
286
|
+
# Reported and left alone rather than normalised. Rewriting a config
|
|
287
|
+
# into the shape this code prefers would discard whatever the user's
|
|
288
|
+
# own tooling put there, and we cannot know what that was for.
|
|
289
|
+
return [], f"FAIL|HOOKS_UNRECOGNISED|{hooks_path}|{HOOKS_MANUAL_FIX} ({problem})"
|
|
290
|
+
|
|
291
|
+
snippet = json.loads(read_snippet("antigravity", "hooks"))
|
|
292
|
+
new_hook = snippet["hooks"]["PreToolUse"][0]
|
|
293
|
+
|
|
294
|
+
pre_tool_use = cfg.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
295
|
+
already = any(
|
|
296
|
+
isinstance(h, dict)
|
|
297
|
+
and h.get("matcher", {}).get("tool") == new_hook["matcher"]["tool"]
|
|
298
|
+
and h.get("matcher", {}).get("commandPattern") == new_hook["matcher"]["commandPattern"]
|
|
299
|
+
for h in pre_tool_use
|
|
300
|
+
)
|
|
301
|
+
if not already:
|
|
302
|
+
pre_tool_use.append(new_hook)
|
|
303
|
+
_write_json(hooks_path, cfg)
|
|
304
|
+
return [f"{hooks_rel.replace(os.sep, '/')} {HOOKS_NOTE}"], None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Detection
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def _detected(host, home):
|
|
312
|
+
"""Whether a host looks installed on this machine.
|
|
313
|
+
|
|
314
|
+
Same heuristic the shell installer used — a config directory — plus the
|
|
315
|
+
binary on
|
|
316
|
+
PATH, which catches a fresh install that has not written a config yet.
|
|
317
|
+
Claude Code has no detection leg: it always installs under `--host all`,
|
|
318
|
+
matching 2.0's behaviour.
|
|
319
|
+
"""
|
|
320
|
+
if host == "claude":
|
|
321
|
+
return True
|
|
322
|
+
if host == "opencode":
|
|
323
|
+
return (os.path.isdir(os.path.join(home, ".config", "opencode"))
|
|
324
|
+
or _on_path("opencode"))
|
|
325
|
+
if host == "antigravity":
|
|
326
|
+
return os.path.isdir(os.path.join(home, ".gemini")) or _on_path("antigravity")
|
|
327
|
+
return False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _on_path(binary):
|
|
331
|
+
import shutil
|
|
332
|
+
return shutil.which(binary) is not None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def hosts_to_install(host, home):
|
|
336
|
+
"""Which hosts a given --host value resolves to.
|
|
337
|
+
|
|
338
|
+
Naming a host installs it regardless of detection: asking for it
|
|
339
|
+
explicitly is itself the signal.
|
|
340
|
+
"""
|
|
341
|
+
if host != "all":
|
|
342
|
+
return [host]
|
|
343
|
+
return [h for h in HOST_DIRS if _detected(h, home)]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Entry point
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
def run_setup(host="all", with_mcp=False, project=None, no_hooks=False):
|
|
351
|
+
"""Install the adapters and return a CommandResult listing what changed."""
|
|
352
|
+
if host not in VALID_HOSTS:
|
|
353
|
+
return CommandResult(
|
|
354
|
+
f"FAIL|INVALID_HOST|{host}|expected one of {', '.join(VALID_HOSTS)}",
|
|
355
|
+
EXIT_VALIDATION,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
global_scope = project is None
|
|
359
|
+
root = _home() if global_scope else os.path.abspath(project)
|
|
360
|
+
selected = hosts_to_install(host, _home())
|
|
361
|
+
|
|
362
|
+
lines = [f"Installing context-guard adapters into {root} ..."]
|
|
363
|
+
touched = []
|
|
364
|
+
failures = []
|
|
365
|
+
|
|
366
|
+
for name in selected:
|
|
367
|
+
if name == "claude":
|
|
368
|
+
touched += _install_claude(root, with_mcp, global_scope)
|
|
369
|
+
lines.append(" -> Claude Code: commands installed, cg approve on the ask list")
|
|
370
|
+
elif name == "opencode":
|
|
371
|
+
touched += _install_opencode(root, with_mcp, global_scope)
|
|
372
|
+
lines.append(" -> OpenCode: commands installed, config merged")
|
|
373
|
+
elif name == "antigravity":
|
|
374
|
+
# One command configures three independent hosts. A host that
|
|
375
|
+
# cannot be configured reports and steps aside: letting it abort
|
|
376
|
+
# the run would let one unrelated file on disk decide that the
|
|
377
|
+
# tool does not work on this machine.
|
|
378
|
+
host_touched, failure = _install_antigravity(root, global_scope, no_hooks)
|
|
379
|
+
touched += host_touched
|
|
380
|
+
if failure:
|
|
381
|
+
failures.append(failure)
|
|
382
|
+
lines.append(" -> Antigravity: hooks.json left untouched, see below")
|
|
383
|
+
elif not global_scope:
|
|
384
|
+
lines.append(" -> Antigravity: rule installed")
|
|
385
|
+
elif no_hooks:
|
|
386
|
+
lines.append(" -> Antigravity: deny hook skipped (--no-hooks)")
|
|
387
|
+
else:
|
|
388
|
+
lines.append(" -> Antigravity: deny hook merged into "
|
|
389
|
+
"~/.gemini/config/hooks.json")
|
|
390
|
+
|
|
391
|
+
for name in HOST_DIRS:
|
|
392
|
+
if name in selected:
|
|
393
|
+
continue
|
|
394
|
+
# Two different reasons to skip, and telling a user their host was
|
|
395
|
+
# "not detected" when they asked for a different one sends them
|
|
396
|
+
# debugging a detection problem they do not have.
|
|
397
|
+
if host == "all":
|
|
398
|
+
lines.append(f" -> {name}: not detected, skipped "
|
|
399
|
+
f"(pass --host {name} to install it anyway)")
|
|
400
|
+
else:
|
|
401
|
+
lines.append(f" -> {name}: skipped (--host {host})")
|
|
402
|
+
|
|
403
|
+
if global_scope:
|
|
404
|
+
lines.append("")
|
|
405
|
+
lines.append("Phase files are materialised per project by `cg new`.")
|
|
406
|
+
lines.append("")
|
|
407
|
+
lines.append("Files touched:")
|
|
408
|
+
if touched:
|
|
409
|
+
lines.extend(f" {path}" for path in touched)
|
|
410
|
+
else:
|
|
411
|
+
lines.append(" (none)")
|
|
412
|
+
|
|
413
|
+
if failures:
|
|
414
|
+
lines.append("")
|
|
415
|
+
lines.append("Failed:")
|
|
416
|
+
lines.extend(f" {failure}" for failure in failures)
|
|
417
|
+
|
|
418
|
+
# A run where one host could not be configured is not a success, even
|
|
419
|
+
# though the others were: exiting 0 would let a caller script past a host
|
|
420
|
+
# that silently has no enforcement.
|
|
421
|
+
return CommandResult("\n".join(lines),
|
|
422
|
+
EXIT_VALIDATION if failures else EXIT_OK)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ---------------------------------------------------------------------------
|
|
426
|
+
# Project scaffolding, used by `cg new`
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
def materialise_phases(context):
|
|
430
|
+
"""Write the embedded phase documents into a project, without clobbering.
|
|
431
|
+
|
|
432
|
+
A phase file that already exists is left exactly as it is, even when it
|
|
433
|
+
differs from the embedded copy: the project may have customised it, and
|
|
434
|
+
silently restoring the stock text would delete a team's own process.
|
|
435
|
+
`cg doctor` surfaces the difference instead.
|
|
436
|
+
"""
|
|
437
|
+
written = []
|
|
438
|
+
phases_dir = os.path.join(context, ".context-guard", "phases")
|
|
439
|
+
from .assets import PHASES, get_phase
|
|
440
|
+
for name in PHASES:
|
|
441
|
+
path = os.path.join(phases_dir, f"{name}.md")
|
|
442
|
+
if os.path.exists(path):
|
|
443
|
+
continue
|
|
444
|
+
_write_text(path, get_phase(name))
|
|
445
|
+
written.append(path)
|
|
446
|
+
return written
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def materialise_antigravity_rule(context):
|
|
450
|
+
"""Write the workspace rule file, unless the project already has one."""
|
|
451
|
+
path = os.path.join(context, ".agents", "rules", "context-guard.md")
|
|
452
|
+
if os.path.exists(path):
|
|
453
|
+
return []
|
|
454
|
+
for relpath, text in iter_host_files("antigravity"):
|
|
455
|
+
if relpath == "rules/context-guard.md":
|
|
456
|
+
_write_text(path, text)
|
|
457
|
+
return [path]
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def antigravity_detected():
|
|
462
|
+
return _detected("antigravity", _home())
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def diverged_phases(context):
|
|
466
|
+
"""Phase files that exist but differ from the embedded copy."""
|
|
467
|
+
from .assets import PHASES, get_phase
|
|
468
|
+
out = []
|
|
469
|
+
for name in PHASES:
|
|
470
|
+
path = os.path.join(context, ".context-guard", "phases", f"{name}.md")
|
|
471
|
+
if not os.path.exists(path):
|
|
472
|
+
continue
|
|
473
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
474
|
+
if f.read() != get_phase(name):
|
|
475
|
+
out.append(f"{name}.md")
|
|
476
|
+
return out
|