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.
Files changed (32) hide show
  1. context_guard/__init__.py +1 -0
  2. context_guard/_data/hosts/antigravity/hooks.snippet.json +16 -0
  3. context_guard/_data/hosts/antigravity/rules/context-guard.md +15 -0
  4. context_guard/_data/hosts/claude-code/commands/cg-continue.md +13 -0
  5. context_guard/_data/hosts/claude-code/commands/cg-new.md +11 -0
  6. context_guard/_data/hosts/claude-code/mcp.snippet.json +7 -0
  7. context_guard/_data/hosts/claude-code/settings.snippet.json +12 -0
  8. context_guard/_data/hosts/opencode/agent.snippet.json +7 -0
  9. context_guard/_data/hosts/opencode/commands/cg-continue.md +16 -0
  10. context_guard/_data/hosts/opencode/commands/cg-new.md +12 -0
  11. context_guard/_data/hosts/opencode/mcp.snippet.json +9 -0
  12. context_guard/_data/hosts/opencode/permissions.snippet.json +12 -0
  13. context_guard/_data/phases/execute.md +99 -0
  14. context_guard/_data/phases/plan.md +136 -0
  15. context_guard/_data/phases/verify.md +129 -0
  16. context_guard/guard/__init__.py +1 -0
  17. context_guard/guard/assets.py +94 -0
  18. context_guard/guard/cli.py +307 -0
  19. context_guard/guard/commands.py +811 -0
  20. context_guard/guard/errors.py +71 -0
  21. context_guard/guard/locking.py +181 -0
  22. context_guard/guard/manifest.py +69 -0
  23. context_guard/guard/migrate.py +288 -0
  24. context_guard/guard/paths.py +199 -0
  25. context_guard/guard/setup.py +476 -0
  26. context_guard/guard/transaction.py +403 -0
  27. context_guard/mcp_server.py +280 -0
  28. context_guard_cli-2.1.0.dist-info/METADATA +296 -0
  29. context_guard_cli-2.1.0.dist-info/RECORD +32 -0
  30. context_guard_cli-2.1.0.dist-info/WHEEL +4 -0
  31. context_guard_cli-2.1.0.dist-info/entry_points.txt +4 -0
  32. 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