Skip to content

Commit 5dd2064

Browse files
committed
Add script for removing OpenSpec artifacts
1 parent 4cf7bf8 commit 5dd2064

File tree

1 file changed

+253
-0
lines changed

1 file changed

+253
-0
lines changed

scripts/remove_openspec.py

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Remove OpenSpec configuration files from a project.
4+
5+
This script deletes the core OpenSpec directory plus integration files for
6+
Codex, Claude Code, and Factory Droid. For AGENTS.md and CLAUDE.md it removes
7+
only the managed OpenSpec block so custom instructions remain intact. Provide
8+
the target project path as the first argument (quotes are supported for paths
9+
that contain spaces). Use --instructions-only to limit cleanup to AGENTS.md and
10+
CLAUDE.md without touching other OpenSpec assets.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import os
17+
import shutil
18+
import sys
19+
from pathlib import Path
20+
from typing import Iterable, Tuple, Literal
21+
22+
23+
def remove_path(target: Path) -> bool:
24+
"""Remove a file or directory if it exists, returning True when deleted."""
25+
try:
26+
if not target.exists():
27+
return False
28+
if target.is_dir() and not target.is_symlink():
29+
shutil.rmtree(target)
30+
else:
31+
target.unlink()
32+
return True
33+
except Exception as exc: # pragma: no cover - best-effort cleanup
34+
print(f"Failed to remove {target}: {exc}", file=sys.stderr)
35+
return False
36+
37+
38+
def remove_many(paths: Iterable[Path]) -> Tuple[int, int]:
39+
"""Attempt to remove multiple paths; return (removed, missing) counts."""
40+
removed = 0
41+
missing = 0
42+
for file_path in paths:
43+
if remove_path(file_path):
44+
print(f"Removed {file_path}")
45+
removed += 1
46+
else:
47+
print(f"Skipped (missing): {file_path}")
48+
missing += 1
49+
return removed, missing
50+
51+
52+
def build_project_targets(project_root: Path) -> Iterable[Path]:
53+
"""Yield project-scoped paths that should be removed."""
54+
openspec_dir = project_root / "openspec"
55+
claude_commands = [
56+
project_root
57+
/ ".claude"
58+
/ "commands"
59+
/ "openspec"
60+
/ f"{name}.md"
61+
for name in ("proposal", "apply", "archive")
62+
]
63+
factory_commands = [
64+
project_root / ".factory" / "commands" / f"openspec-{name}.md"
65+
for name in ("proposal", "apply", "archive")
66+
]
67+
68+
for target in [openspec_dir, *claude_commands]:
69+
yield target
70+
71+
for target in factory_commands:
72+
yield target
73+
74+
75+
def build_codex_targets(prompts_dir: Path) -> Iterable[Path]:
76+
"""Yield Codex prompt files that should be removed."""
77+
for name in ("proposal", "apply", "archive"):
78+
yield prompts_dir / f"openspec-{name}.md"
79+
80+
81+
def prune_empty(paths: Iterable[Path]) -> None:
82+
"""Remove empty directories, ignoring errors."""
83+
for dir_path in paths:
84+
try:
85+
if dir_path.exists() and dir_path.is_dir():
86+
# Check whether directory is empty before removing.
87+
if not any(dir_path.iterdir()):
88+
dir_path.rmdir()
89+
print(f"Removed empty directory {dir_path}")
90+
except Exception as exc: # pragma: no cover - best-effort cleanup
91+
print(f"Could not prune {dir_path}: {exc}", file=sys.stderr)
92+
93+
94+
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
95+
parser = argparse.ArgumentParser(
96+
description="Remove OpenSpec files from a project."
97+
)
98+
parser.add_argument(
99+
"project_path",
100+
help="Path to the project that contains OpenSpec files.",
101+
)
102+
parser.add_argument(
103+
"--instructions-only",
104+
action="store_true",
105+
help="Only remove OpenSpec managed blocks from AGENTS.md and CLAUDE.md.",
106+
)
107+
return parser.parse_args(argv)
108+
109+
110+
def strip_managed_block(file_path: Path) -> Literal["missing", "no_marker", "stripped", "deleted", "incomplete"]:
111+
"""Remove the OpenSpec managed block from a file, preserving custom content."""
112+
if not file_path.exists() or not file_path.is_file():
113+
return "missing"
114+
115+
try:
116+
content = file_path.read_text(encoding="utf-8")
117+
except OSError as exc: # pragma: no cover - best-effort cleanup
118+
print(f"Failed to read {file_path}: {exc}", file=sys.stderr)
119+
return "missing"
120+
121+
start_marker = "<!-- OPENSPEC:START -->"
122+
end_marker = "<!-- OPENSPEC:END -->"
123+
if start_marker not in content or end_marker not in content:
124+
return "no_marker"
125+
126+
lines = content.splitlines()
127+
kept_lines = []
128+
inside = False
129+
removed = False
130+
131+
for line in lines:
132+
start = start_marker in line
133+
end = end_marker in line
134+
135+
if start and end:
136+
removed = True
137+
inside = False
138+
continue
139+
140+
if start:
141+
inside = True
142+
removed = True
143+
continue
144+
145+
if inside:
146+
if end:
147+
inside = False
148+
continue
149+
150+
if end:
151+
# Encountered end marker without matching start.
152+
return "incomplete"
153+
154+
kept_lines.append(line)
155+
156+
if inside:
157+
# Unmatched start marker; don't modify the file.
158+
return "incomplete"
159+
160+
if not removed:
161+
return "no_marker"
162+
163+
# Trim leading/trailing blank lines introduced by removal.
164+
while kept_lines and not kept_lines[0].strip():
165+
kept_lines.pop(0)
166+
while kept_lines and not kept_lines[-1].strip():
167+
kept_lines.pop()
168+
169+
if kept_lines:
170+
new_content = "\n".join(kept_lines) + "\n"
171+
try:
172+
file_path.write_text(new_content, encoding="utf-8")
173+
except OSError as exc: # pragma: no cover - best-effort cleanup
174+
print(f"Failed to write {file_path}: {exc}", file=sys.stderr)
175+
return "incomplete"
176+
return "stripped"
177+
178+
try:
179+
file_path.unlink()
180+
except OSError as exc: # pragma: no cover - best-effort cleanup
181+
print(f"Failed to delete {file_path}: {exc}", file=sys.stderr)
182+
return "incomplete"
183+
return "deleted"
184+
185+
186+
def cleanup_instruction_file(file_path: Path) -> Literal["missing", "no_marker", "stripped", "deleted", "incomplete"]:
187+
"""Surgically remove the managed block from instruction files."""
188+
result = strip_managed_block(file_path)
189+
if result == "missing":
190+
print(f"Skipped (missing): {file_path}")
191+
elif result == "no_marker":
192+
print(f"No OpenSpec block found in {file_path}")
193+
elif result == "stripped":
194+
print(f"Removed OpenSpec block from {file_path}")
195+
elif result == "deleted":
196+
print(f"Removed OpenSpec block and deleted empty file {file_path}")
197+
elif result == "incomplete":
198+
print(f"Could not fully remove block in {file_path}; markers may be unmatched.")
199+
return result
200+
201+
202+
def main(argv: Iterable[str]) -> int:
203+
args = parse_args(argv)
204+
project_root = Path(args.project_path).expanduser().resolve()
205+
206+
if not project_root.exists():
207+
print(f"Project path does not exist: {project_root}", file=sys.stderr)
208+
return 1
209+
if not project_root.is_dir():
210+
print(f"Project path is not a directory: {project_root}", file=sys.stderr)
211+
return 1
212+
213+
print(f"Removing OpenSpec files from {project_root}")
214+
215+
instruction_results = [
216+
cleanup_instruction_file(project_root / "AGENTS.md"),
217+
cleanup_instruction_file(project_root / "CLAUDE.md"),
218+
]
219+
stripped_count = sum(result in {"stripped", "deleted"} for result in instruction_results)
220+
221+
if args.instructions_only:
222+
print(f"Cleanup complete. Updated {stripped_count} instruction file(s).")
223+
return 0
224+
225+
project_removed, _ = remove_many(build_project_targets(project_root))
226+
227+
# Clean up Codex prompt files
228+
codex_home = Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
229+
prompts_dir = codex_home / "prompts"
230+
codex_removed, _ = remove_many(build_codex_targets(prompts_dir))
231+
232+
# Attempt to prune empty directories created for integrations.
233+
prune_empty(
234+
[
235+
prompts_dir,
236+
project_root / ".claude" / "commands" / "openspec",
237+
project_root / ".claude" / "commands",
238+
project_root / ".claude",
239+
project_root / ".factory" / "commands",
240+
project_root / ".factory",
241+
]
242+
)
243+
244+
print(
245+
f"Cleanup complete. Removed {project_removed} project item(s), "
246+
f"{codex_removed} Codex item(s), and updated {stripped_count} instruction file(s)."
247+
)
248+
print("You may manually remove any remaining empty folders if desired.")
249+
return 0
250+
251+
252+
if __name__ == "__main__":
253+
sys.exit(main(sys.argv[1:]))

0 commit comments

Comments
 (0)