Skip to content

Commit fd7575f

Browse files
Bordaclaude[bot]
andcommitted
feat(codex): sync clear teardown + --remove mode
- sync.sh: add `clear` positional — teardown mode that uninstalls this marketplace's Claude plugins and the Codex Rig plugin, then strips the managed block from $CODEX_HOME/AGENTS.md; honors claude/codex scoping and leaves marketplace registrations plus external plugins in place - install_global_agents.py: add `--remove` mode (marker-aware stripped_payload + remove_global_agents) — keeps a timestamped backup, deletes AGENTS.md when it held only the managed block, refuses a tampered block without writing; --source is now optional under --remove - tests: 6 cases covering remove (delete-solely-ours, strip-preserving-user-content, absent no-op, refuse-tampered-block, arg validation) and the sync clear teardown wiring - bump codex-rig 0.2.2 -> 0.3.0 (new capability) - document `clear` teardown in root and codex-rig READMEs package-manifest.json is intentionally left out — it is a generated artifact under concurrent edit; a single regen covering all changes lands separately. --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent b38d5b2 commit fd7575f

6 files changed

Lines changed: 213 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ Start a fresh session. The plugin uses exact role-card injection for parallel bl
304304
305305
### Product boundary
306306
307-
`plugins/codex-rig/` is the installable source of truth for workflows, role cards, shared gates, calibration, hooks, lifecycle tooling, and an inert global-instructions template. Direct `codex plugin` installation leaves global and project instructions untouched. From this checkout, plain `bash sync.sh` performs the full Claude + Codex restore; `bash sync.sh codex` limits it to Codex. Both install or update one backup-protected block in `$CODEX_HOME/AGENTS.md` by default. Pass `--no-codex-global-agents` to skip it; `bash sync.sh claude` changes only Claude scope. Project `AGENTS.md` files and the installed plugin cache remain untouched. `--codex-ref REF` remains a Codex source selector.
307+
`plugins/codex-rig/` is the installable source of truth for workflows, role cards, shared gates, calibration, hooks, lifecycle tooling, and an inert global-instructions template. Direct `codex plugin` installation leaves global and project instructions untouched. From this checkout, plain `bash sync.sh` performs the full Claude + Codex restore; `bash sync.sh codex` limits it to Codex. Both install or update one backup-protected block in `$CODEX_HOME/AGENTS.md` by default. Pass `--no-codex-global-agents` to skip it; `bash sync.sh claude` changes only Claude scope. Project `AGENTS.md` files and the installed plugin cache remain untouched. `--codex-ref REF` remains a Codex source selector. `bash sync.sh clear` reverses a sync: it uninstalls this marketplace's Claude plugins and the Codex Rig plugin, then strips the managed `$CODEX_HOME/AGENTS.md` block (timestamped backup kept, user content preserved), honoring `claude`/`codex` scope and leaving marketplace registrations plus external plugins in place.
308308
309309
Use `$codex-rig:sync` for a dry-run installation report and approval-gated marketplace refresh. Older AI-Rig versions copied files into `~/.codex/`; because those files had no durable ownership marker, they require a separate backup and ownership review before manual cleanup.
310310

plugins/codex-rig/.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,5 @@
3030
"name": "codex-rig",
3131
"repository": "https://github.com/Borda/AI-Rig",
3232
"skills": "./skills/",
33-
"version": "0.2.2"
33+
"version": "0.3.0"
3434
}

plugins/codex-rig/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ From an AI-Rig checkout:
5757
bash sync.sh # full Claude + Codex restore
5858
bash sync.sh codex # Codex scope only
5959
bash sync.sh codex --no-codex-global-agents # skip global guidance
60+
bash sync.sh clear # teardown: uninstall plugins + strip managed block
61+
bash sync.sh clear codex # teardown Codex scope only
6062
```
6163

6264
`bash sync.sh claude` changes only Claude scope. `--codex-ref REF` selects a Codex source revision; it does not change product scope.
6365

66+
`bash sync.sh clear` reverses a sync instead of installing: it uninstalls this marketplace's Claude plugins and the Codex Rig plugin, then strips the managed block from `${CODEX_HOME:-$HOME/.codex}/AGENTS.md`, keeping a timestamped backup and preserving user-owned content byte-for-byte. It honors `claude`/`codex` scoping and leaves marketplace registrations plus external plugins in place. A tampered managed block makes the strip fail without writing, exactly like install.
67+
6468
Codex sync uses the template from the installed marketplace revision. A missing global file is created as one SHA-256-authenticated managed block. Existing user instructions are backed up and preserved byte-for-byte outside that block. An exact unmarked copy from an older sync is adopted without duplication. Later runs update only an unmodified managed block and otherwise fail without writing when markers are missing, duplicated, malformed, or manually changed.
6569

6670
Marketplace/plugin refresh can finish before a later global-instruction merge fails; resolve the reported target state and rerun sync. Avoid concurrent edits during restoration. The installer rechecks observed bytes immediately before atomic replacement, but portable filesystems provide no universal compare-and-swap operation.

plugins/codex-rig/scripts/install_global_agents.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env python3
2-
"""Safely install or update Codex Rig's managed global-instruction block."""
2+
"""Safely install, update, or remove Codex Rig's managed global-instruction block."""
33

44
from __future__ import annotations
55

@@ -74,6 +74,38 @@ def merged_payload(existing: bytes, block: bytes) -> tuple[bytes, str]:
7474
return updated, "already current" if updated == existing else "updated"
7575

7676

77+
def stripped_payload(existing: bytes) -> tuple[bytes, str]:
78+
"""Remove one authenticated managed block, preserving every external byte."""
79+
try:
80+
existing.decode("utf-8")
81+
except UnicodeDecodeError as error:
82+
raise UnsafeGlobalAgentsState("existing AGENTS.md is not UTF-8; refusing write") from error
83+
84+
begin_count = existing.count(BEGIN_PREFIX)
85+
end_count = existing.count(END_MARKER.rstrip(b"\n"))
86+
if begin_count == 0 and end_count == 0:
87+
return existing, "absent"
88+
if begin_count != 1 or end_count != 1:
89+
raise UnsafeGlobalAgentsState("managed markers are malformed or duplicated; refusing write")
90+
91+
begin_match = BEGIN_PATTERN.search(existing)
92+
if begin_match is None:
93+
raise UnsafeGlobalAgentsState("managed begin marker is malformed; refusing write")
94+
end_index = existing.find(END_MARKER, begin_match.end())
95+
if end_index < 0:
96+
raise UnsafeGlobalAgentsState("managed end marker is malformed; refusing write")
97+
body = existing[begin_match.end() : end_index]
98+
if sha256(body) != begin_match.group(1).decode("ascii"):
99+
raise UnsafeGlobalAgentsState("managed block was modified; refusing write")
100+
101+
block_end = end_index + len(END_MARKER)
102+
updated = existing[: begin_match.start()] + existing[block_end:]
103+
# collapse the single separator install prepended so removal leaves no doubled blank line
104+
if updated.endswith(b"\n\n") and existing[: begin_match.start()].endswith(b"\n\n"):
105+
updated = updated[:-1]
106+
return updated, "removed"
107+
108+
77109
def backup_target(target: Path, codex_home: Path, payload: bytes) -> Path:
78110
"""Create and verify a unique backup before changing an existing target."""
79111
backup_root = codex_home / "backups" / "codex-rig"
@@ -140,19 +172,50 @@ def install_global_agents(source: Path, codex_home: Path) -> tuple[str, Path, Pa
140172
return action, target, backup
141173

142174

175+
def remove_global_agents(codex_home: Path) -> tuple[str, Path, Path | None]:
176+
"""Strip Codex Rig's managed block from AGENTS.md without touching user content."""
177+
target = codex_home / "AGENTS.md"
178+
if not target.exists():
179+
return "absent", target, None
180+
if target.is_symlink():
181+
raise UnsafeGlobalAgentsState(f"target is a symlink; refusing write: {target}")
182+
if not target.is_file():
183+
raise UnsafeGlobalAgentsState(f"target is not an ordinary file; refusing write: {target}")
184+
185+
existing = target.read_bytes()
186+
updated, action = stripped_payload(existing)
187+
if action == "absent":
188+
return "absent", target, None
189+
190+
backup = backup_target(target, codex_home, existing)
191+
if updated.strip() == b"":
192+
target.unlink() # file held only our block — remove it entirely
193+
return "removed-file", target, backup
194+
mode = stat.S_IMODE(target.stat().st_mode)
195+
atomic_write(target, updated, mode, existing)
196+
return "removed-block", target, backup
197+
198+
143199
def parse_args() -> argparse.Namespace:
144200
"""Parse explicit source and Codex-home paths."""
145201
parser = argparse.ArgumentParser(description=__doc__)
146-
parser.add_argument("--source", type=Path, required=True, help="packaged assets/AGENTS.md template")
202+
parser.add_argument("--source", type=Path, help="packaged assets/AGENTS.md template (required unless --remove)")
147203
parser.add_argument("--codex-home", type=Path, required=True, help="target Codex home")
148-
return parser.parse_args()
204+
parser.add_argument("--remove", action="store_true", help="strip the managed block instead of installing it")
205+
args = parser.parse_args()
206+
if not args.remove and args.source is None:
207+
parser.error("--source is required unless --remove is given")
208+
return args
149209

150210

151211
def main() -> int:
152-
"""Run one fail-closed global-instruction installation."""
212+
"""Run one fail-closed global-instruction installation or removal."""
153213
args = parse_args()
154214
try:
155-
action, target, backup = install_global_agents(args.source, args.codex_home)
215+
if args.remove:
216+
action, target, backup = remove_global_agents(args.codex_home)
217+
else:
218+
action, target, backup = install_global_agents(args.source, args.codex_home)
156219
except UnsafeGlobalAgentsState as error:
157220
print(f"global-agents-error: {error}", file=sys.stderr)
158221
return 4

plugins/codex-rig/tests/test_global_agents_installer.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ def run_installer(
3636
)
3737

3838

39+
def run_remover(codex_home: Path) -> subprocess.CompletedProcess[str]:
40+
"""Run the packaged installer in --remove mode against one isolated Codex home."""
41+
return subprocess.run(
42+
[sys.executable, str(INSTALLER), "--remove", "--codex-home", str(codex_home)],
43+
capture_output=True,
44+
text=True,
45+
check=False,
46+
)
47+
48+
3949
def managed_body(payload: bytes) -> tuple[str, bytes]:
4050
"""Return the recorded digest and exact body bytes from one managed block."""
4151
begin_prefix = BEGIN_PREFIX.encode("ascii")
@@ -222,6 +232,102 @@ def test_atomic_write_refuses_target_drift_before_replace(tmp_path: Path) -> Non
222232
assert target.read_bytes() == b"concurrent edit\n"
223233

224234

235+
def test_remove_deletes_file_that_held_only_managed_block(tmp_path: Path) -> None:
236+
"""Prove teardown deletes an AGENTS.md that Codex Rig alone created."""
237+
source = tmp_path / "template.md"
238+
source.write_text("managed policy\n", encoding="utf-8")
239+
codex_home = tmp_path / "codex-home"
240+
assert run_installer(source, codex_home).returncode == 0
241+
target = codex_home / "AGENTS.md"
242+
original = target.read_bytes()
243+
244+
result = run_remover(codex_home)
245+
246+
assert result.returncode == 0, result.stderr
247+
assert not target.exists()
248+
assert "removed-file" in result.stdout
249+
backups = list((codex_home / "backups" / "codex-rig").glob("*-AGENTS.md"))
250+
assert len(backups) == 1
251+
assert backups[0].read_bytes() == original
252+
253+
254+
def test_remove_strips_block_and_preserves_user_content(tmp_path: Path) -> None:
255+
"""Prove teardown removes only the managed block, keeping user-owned guidance."""
256+
source = tmp_path / "template.md"
257+
source.write_text("managed policy\n", encoding="utf-8")
258+
codex_home = tmp_path / "codex-home"
259+
codex_home.mkdir()
260+
target = codex_home / "AGENTS.md"
261+
user_content = "# User policy\n\nKeep this exactly.\n"
262+
target.write_text(user_content, encoding="utf-8")
263+
assert run_installer(source, codex_home).returncode == 0
264+
265+
result = run_remover(codex_home)
266+
267+
assert result.returncode == 0, result.stderr
268+
assert target.read_text(encoding="utf-8") == user_content
269+
assert BEGIN_PREFIX not in target.read_text(encoding="utf-8")
270+
assert "removed-block" in result.stdout
271+
272+
273+
def test_remove_is_noop_when_no_managed_block_present(tmp_path: Path) -> None:
274+
"""Prove teardown leaves an unmanaged AGENTS.md untouched and reports absent."""
275+
codex_home = tmp_path / "codex-home"
276+
codex_home.mkdir()
277+
target = codex_home / "AGENTS.md"
278+
target.write_text("# only user content\n", encoding="utf-8")
279+
before = target.read_bytes()
280+
281+
result = run_remover(codex_home)
282+
283+
assert result.returncode == 0, result.stderr
284+
assert target.read_bytes() == before
285+
assert "absent" in result.stdout
286+
287+
288+
def test_remove_refuses_modified_managed_block(tmp_path: Path) -> None:
289+
"""Fail without writes when the managed block was tampered with before teardown."""
290+
source = tmp_path / "template.md"
291+
source.write_text("managed policy\n", encoding="utf-8")
292+
codex_home = tmp_path / "codex-home"
293+
assert run_installer(source, codex_home).returncode == 0
294+
target = codex_home / "AGENTS.md"
295+
target.write_text(
296+
target.read_text(encoding="utf-8").replace("managed policy", "manually changed"), encoding="utf-8"
297+
)
298+
before = target.read_bytes()
299+
300+
result = run_remover(codex_home)
301+
302+
assert result.returncode == 4
303+
assert target.read_bytes() == before
304+
assert "refusing" in result.stderr.lower()
305+
306+
307+
def test_remove_requires_no_source_argument(tmp_path: Path) -> None:
308+
"""Prove --remove needs only --codex-home while install still requires --source."""
309+
missing_source = subprocess.run(
310+
[sys.executable, str(INSTALLER), "--codex-home", str(tmp_path)],
311+
capture_output=True,
312+
text=True,
313+
check=False,
314+
)
315+
316+
assert missing_source.returncode == 2
317+
assert "--source is required unless --remove" in missing_source.stderr
318+
319+
320+
def test_sync_clear_teardown_wiring() -> None:
321+
"""Bind the clear teardown path (plugin uninstall + AGENTS.md strip) to installed sync bytes."""
322+
sync_path = PLUGIN_ROOT.parents[1] / "sync.sh"
323+
script = sync_path.read_text(encoding="utf-8")
324+
assert "clear) CLEAR=true ;;" in script
325+
assert "if $CLEAR; then" in script
326+
assert 'claude plugin uninstall "${p}@${MARKETPLACE}"' in script
327+
assert 'codex plugin remove "$CODEX_PLUGIN"' in script
328+
assert '--remove --codex-home "${CODEX_HOME:-$HOME/.codex}"' in script
329+
330+
225331
def test_sync_global_agents_defaults_on_with_negative_opt_out() -> None:
226332
"""Bind full-restore defaults and opt-out wiring to installed-plugin bytes."""
227333
sync_path = PLUGIN_ROOT.parents[1] / "sync.sh"

sync.sh

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
#!/usr/bin/env bash
22
# Install AI-Rig plugins for Claude Code and/or Codex from the GitHub remote.
33
# Remote installs use pushed state — commit and push before running; release tags are optional pins.
4-
# Run from the project root: bash sync.sh [claude] [codex] [--no-clean] [--codex-ref REF] [--no-codex-global-agents]
4+
# Run from the project root: bash sync.sh [claude] [codex] [clear] [--no-clean] [--codex-ref REF] [--no-codex-global-agents]
55
#
66
# Arguments (order-independent):
77
# claude — sync Claude plugins + foundry:setup (default: both)
88
# codex — install or update the Codex Rig plugin (default: both)
9+
# clear — teardown instead of install: uninstall this marketplace's Claude plugins
10+
# + the Codex Rig plugin, and strip the managed block from $CODEX_HOME/AGENTS.md
11+
# (a timestamped backup is kept). Honors claude/codex scoping (default: both sides).
12+
# Leaves marketplace registrations and external plugins (caveman/ponytail/openai-codex) in place.
913
# --no-clean — skip uninstall before reinstalling (default: uninstall first)
1014
# --codex-ref REF — pin Codex Rig to one Git ref (default: latest default branch)
1115
# --no-codex-global-agents — skip Codex Rig's managed block in $CODEX_HOME/AGENTS.md
@@ -17,13 +21,15 @@ set -e
1721
SYNC_CLAUDE=false
1822
SYNC_CODEX=false
1923
CLEAN=true
24+
CLEAR=false
2025
CODEX_REF=""
2126
INSTALL_CODEX_GLOBAL_AGENTS=true
2227

2328
while [[ $# -gt 0 ]]; do
2429
case "$1" in
2530
claude) SYNC_CLAUDE=true ;;
2631
codex) SYNC_CODEX=true ;;
32+
clear) CLEAR=true ;;
2733
--no-clean) CLEAN=false ;;
2834
--codex-ref)
2935
shift
@@ -72,6 +78,32 @@ CODEX_MARKETPLACE="borda-ai-rig"
7278
CODEX_MARKETPLACE_SOURCE="Borda/AI-Rig"
7379
CODEX_PLUGIN="codex-rig@${CODEX_MARKETPLACE}"
7480

81+
if $CLEAR; then
82+
if $SYNC_CLAUDE; then
83+
echo "Clearing Claude marketplace plugins..."
84+
for p in "${PLUGINS[@]}"; do
85+
claude plugin uninstall "${p}@${MARKETPLACE}" 2>/dev/null && echo " ✓ uninstalled ${p}" || echo "${p} not installed, skipping"
86+
done
87+
fi
88+
if $SYNC_CODEX; then
89+
echo "Clearing Codex Rig..."
90+
if command -v codex >/dev/null 2>&1; then
91+
codex plugin remove "$CODEX_PLUGIN" 2>/dev/null && echo " ✓ removed $CODEX_PLUGIN" || echo "$CODEX_PLUGIN not installed, skipping"
92+
else
93+
echo " – codex CLI not found, skipping plugin removal"
94+
fi
95+
# strip the managed AGENTS.md block via the local script — no --source needed for --remove
96+
REMOVE_SCRIPT="$PROJECT_DIR/plugins/codex-rig/scripts/install_global_agents.py"
97+
if command -v python3 >/dev/null 2>&1 && [[ -f "$REMOVE_SCRIPT" ]]; then
98+
python3 "$REMOVE_SCRIPT" --remove --codex-home "${CODEX_HOME:-$HOME/.codex}"
99+
else
100+
echo " – cannot strip \$CODEX_HOME/AGENTS.md (python3 or script missing)"
101+
fi
102+
fi
103+
echo "✓ Cleared (Standard: plugins uninstalled; marketplace registrations + external plugins left in place)"
104+
exit 0
105+
fi
106+
75107
print_claude_plugin_identity() {
76108
local plugin_id="$1"
77109
local identity

0 commit comments

Comments
 (0)