code-jig 0.12.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.
- code_jig-0.12.0.dist-info/METADATA +154 -0
- code_jig-0.12.0.dist-info/RECORD +52 -0
- code_jig-0.12.0.dist-info/WHEEL +4 -0
- code_jig-0.12.0.dist-info/entry_points.txt +2 -0
- code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
- codejig/__init__.py +1 -0
- codejig/__main__.py +106 -0
- codejig/_core/VERSION +2 -0
- codejig/_core/bench/requirements.toml +4 -0
- codejig/_core/core/AGENTS.core.md +9 -0
- codejig/_core/core/agents/explorer.toml +24 -0
- codejig/_core/core/agents/judge.toml +19 -0
- codejig/_core/core/agents/reviewer.toml +10 -0
- codejig/_core/core/tiers.toml +4 -0
- codejig/_core/skills/bug-hunt/SKILL.md +131 -0
- codejig/_core/skills/code-analysis/SKILL.md +72 -0
- codejig/_core/skills/codegen/SKILL.md +46 -0
- codejig/_core/skills/cross-review/SKILL.md +32 -0
- codejig/_core/skills/grilling/SKILL.md +60 -0
- codejig/_core/skills/hld-trace/SKILL.md +69 -0
- codejig/_core/skills/improve/SKILL.md +38 -0
- codejig/_core/skills/redacao/SKILL.md +114 -0
- codejig/_core/skills/redacao/checar.py +75 -0
- codejig/_core/skills/redacao/reference.md +118 -0
- codejig/_core/skills/regression-tests/SKILL.md +53 -0
- codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
- codejig/_core/skills/skill-validate/SKILL.md +58 -0
- codejig/_core/skills/web-consensus/SKILL.md +55 -0
- codejig/analyze.py +499 -0
- codejig/atomicwrite.py +49 -0
- codejig/collect.py +311 -0
- codejig/doctor.py +370 -0
- codejig/evidence.py +418 -0
- codejig/feedback.py +165 -0
- codejig/gitgate.py +278 -0
- codejig/hooks.py +64 -0
- codejig/humanize.py +206 -0
- codejig/install.py +381 -0
- codejig/managed.py +64 -0
- codejig/migrate.py +515 -0
- codejig/overlay.py +43 -0
- codejig/paths.py +40 -0
- codejig/probe.py +136 -0
- codejig/review.py +73 -0
- codejig/scrub.py +255 -0
- codejig/skillcheck.py +400 -0
- codejig/skilllint.py +99 -0
- codejig/sources.py +248 -0
- codejig/tiers.py +63 -0
- codejig/tokens.py +118 -0
- codejig/trace.py +137 -0
- codejig/watermark.py +147 -0
codejig/migrate.py
ADDED
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import errno
|
|
5
|
+
import functools
|
|
6
|
+
import hashlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sqlite3
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from codejig import collect, install, paths
|
|
18
|
+
from codejig.atomicwrite import atomic_write
|
|
19
|
+
from codejig.overlay import Overlay, load
|
|
20
|
+
|
|
21
|
+
NOTICE = "code-jig migrate is deprecated and will be removed in the next release"
|
|
22
|
+
KEYS = ("email", "allowProse")
|
|
23
|
+
OLD_MARKER = "generated by codex-harness sha256:"
|
|
24
|
+
OLD_STATUSES = ("codex-harness collect", "codex-harness watermark")
|
|
25
|
+
OLD_SIDECAR = ".codex-harness"
|
|
26
|
+
OLD_LAUNCHER_MARK = "codex-harness launcher "
|
|
27
|
+
SHARED_DEV_SKILLS = {"grilling", "web-consensus", "remove-ai-attribution", "skill-validate", "bug-hunt"}
|
|
28
|
+
OLD_LINK = re.compile(r"(?:^|[/\\])(codex-harness|dev-harness)[/\\]skills[/\\]([^/\\]+)[/\\]?$")
|
|
29
|
+
OLD_NAMES = ("CODEX_HARNESS_", "codex-harness")
|
|
30
|
+
SKIPPED = "skipped the remaining steps because of the refused or failed line above; fix it and rerun"
|
|
31
|
+
GATE_OFF = "failed: the git gate is off until code-jig install succeeds"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def old_state(home: Path) -> Path:
|
|
35
|
+
return home / "codex-harness"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _has_rows(path: Path) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
with contextlib.closing(sqlite3.connect(path)) as con:
|
|
41
|
+
tables = [r[0] for r in con.execute("SELECT name FROM sqlite_master WHERE type = 'table'")]
|
|
42
|
+
return any(con.execute(f'SELECT count(*) FROM "{t}"').fetchone()[0] for t in tables)
|
|
43
|
+
except sqlite3.DatabaseError:
|
|
44
|
+
return True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _move(src: Path, dst: Path) -> str | None:
|
|
48
|
+
in_use = f"refused: {src} is in use; close Codex and Claude Code and rerun"
|
|
49
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
try:
|
|
51
|
+
os.replace(src, dst)
|
|
52
|
+
return None
|
|
53
|
+
except PermissionError:
|
|
54
|
+
return in_use
|
|
55
|
+
except OSError as e:
|
|
56
|
+
if e.errno != errno.EXDEV:
|
|
57
|
+
raise
|
|
58
|
+
tmp = dst.with_name(dst.name + ".migrate-tmp")
|
|
59
|
+
try:
|
|
60
|
+
shutil.copy2(src, tmp)
|
|
61
|
+
os.replace(tmp, dst)
|
|
62
|
+
finally:
|
|
63
|
+
tmp.unlink(missing_ok=True)
|
|
64
|
+
try:
|
|
65
|
+
src.unlink()
|
|
66
|
+
except OSError:
|
|
67
|
+
dst.unlink()
|
|
68
|
+
return in_use
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def move_db(home: Path, dry_run: bool = False) -> list[str]:
|
|
73
|
+
old, new = old_state(home) / "usage.sqlite3", collect.db_path()
|
|
74
|
+
if not old.is_file():
|
|
75
|
+
return []
|
|
76
|
+
if old.with_name(old.name + "-journal").exists():
|
|
77
|
+
return [f"refused: {old} has an unfinished write; open and close Codex once and rerun"]
|
|
78
|
+
if new.exists() and _has_rows(new):
|
|
79
|
+
return [f"refused: {new} already has data; collect rebuilds it from the rollouts, so move {new} away and rerun"]
|
|
80
|
+
if not dry_run:
|
|
81
|
+
refused = _move(old, new)
|
|
82
|
+
if refused:
|
|
83
|
+
return [refused]
|
|
84
|
+
return [f"{'would ' if dry_run else ''}move {old} to {new}"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _git(*args: str) -> subprocess.CompletedProcess | None:
|
|
88
|
+
try:
|
|
89
|
+
return subprocess.run(["git", *args], capture_output=True, text=True, encoding="utf-8", check=False)
|
|
90
|
+
except OSError:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _config(repo: Path | None, *args: str) -> subprocess.CompletedProcess | None:
|
|
95
|
+
scope = ["-C", str(repo), "config", "--local"] if repo else ["config", "--global"]
|
|
96
|
+
return _git(*scope, *args)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _values(repo: Path | None, key: str) -> list[str]:
|
|
100
|
+
r = _config(repo, "--get-all", "-z", key)
|
|
101
|
+
return r.stdout.split("\0")[:-1] if r and r.returncode == 0 else []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _write(repo: Path | None, *args: str) -> str | None:
|
|
105
|
+
r = _config(repo, *args)
|
|
106
|
+
if r and r.returncode == 0:
|
|
107
|
+
return None
|
|
108
|
+
why = r.stderr.strip() if r else "git not found"
|
|
109
|
+
return f"failed: git config {' '.join(args)} ({'global' if repo is None else repo}): {why}"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_work_tree(path: Path) -> bool:
|
|
113
|
+
r = _git("-C", str(path), "rev-parse", "--is-inside-work-tree")
|
|
114
|
+
return bool(r) and r.stdout.strip() == "true"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def default_repos() -> list[Path]:
|
|
118
|
+
return [Path.cwd()] if _is_work_tree(Path.cwd()) else []
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def move_git_config(repos: list[Path], dry_run: bool = False) -> list[str]:
|
|
122
|
+
lines = []
|
|
123
|
+
p = "would " if dry_run else ""
|
|
124
|
+
for repo in [None, *repos]:
|
|
125
|
+
if repo is not None and not _is_work_tree(repo):
|
|
126
|
+
lines.append(f"skip {repo}: not a git repo")
|
|
127
|
+
continue
|
|
128
|
+
where = "global" if repo is None else str(repo)
|
|
129
|
+
for key in KEYS:
|
|
130
|
+
old, new = f"codexharness.{key}", f"codejig.{key}"
|
|
131
|
+
olds = [v or "true" for v in _values(repo, old)] if key == "allowProse" else _values(repo, old)
|
|
132
|
+
if not olds:
|
|
133
|
+
continue
|
|
134
|
+
have = _values(repo, new)
|
|
135
|
+
for value in olds:
|
|
136
|
+
if value in have:
|
|
137
|
+
continue
|
|
138
|
+
if dry_run:
|
|
139
|
+
have.append(value)
|
|
140
|
+
lines.append(f"{p}copy {old} = {value} to {new} ({where})")
|
|
141
|
+
continue
|
|
142
|
+
failed = _write(repo, "--add", new, value)
|
|
143
|
+
lines.append(failed or f"copy {old} = {value} to {new} ({where})")
|
|
144
|
+
if not dry_run:
|
|
145
|
+
if not set(olds) <= set(_values(repo, new)):
|
|
146
|
+
lines.append(f"failed: kept {old} ({where}) because {new} does not hold every value")
|
|
147
|
+
continue
|
|
148
|
+
failed = _write(repo, "--unset-all", old)
|
|
149
|
+
if failed:
|
|
150
|
+
lines.append(failed)
|
|
151
|
+
continue
|
|
152
|
+
lines.append(f"{p}unset {old} ({where})")
|
|
153
|
+
return lines
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _old_hooks_path(home: Path) -> str | None:
|
|
157
|
+
current = install._git_config("--get", "core.hooksPath")
|
|
158
|
+
return current if current and install._same_path(current, old_state(home) / "git-hooks") else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def release_hooks_path(home: Path, dry_run: bool = False) -> list[str]:
|
|
162
|
+
current = _old_hooks_path(home)
|
|
163
|
+
if not current:
|
|
164
|
+
return []
|
|
165
|
+
failed = None if dry_run else _write(None, "--unset", "core.hooksPath")
|
|
166
|
+
if failed:
|
|
167
|
+
return [failed]
|
|
168
|
+
return [f"{'would ' if dry_run else ''}unset core.hooksPath {current}"]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def claude_settings() -> Path:
|
|
172
|
+
return Path.home() / ".claude" / "settings.json"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def claude_skills() -> Path:
|
|
176
|
+
return Path.home() / ".claude" / "skills"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _old_generated(path: Path) -> bool | None:
|
|
180
|
+
try:
|
|
181
|
+
data = path.read_bytes().removeprefix(install.BOM)
|
|
182
|
+
except OSError:
|
|
183
|
+
return None
|
|
184
|
+
first, _, body = data.partition(b"\n")
|
|
185
|
+
for head, tail in install.STYLES.values():
|
|
186
|
+
prefix, suffix = (head + OLD_MARKER).encode(), (install.MARKER_NOTE + tail).encode()
|
|
187
|
+
if first.startswith(prefix):
|
|
188
|
+
digest = first[len(prefix): len(first) - len(suffix)].decode("ascii", "replace")
|
|
189
|
+
return first.endswith(suffix) and hashlib.sha256(body).hexdigest() == digest
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
OLD_FILE_GLOBS = ("AGENTS.md", "*.config.toml", "agents/*.toml", "rules/codex-harness-*.rules")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _old_files(home: Path) -> list[Path]:
|
|
197
|
+
return [f for g in OLD_FILE_GLOBS for f in sorted(home.glob(g))]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _old_backups(home: Path) -> list[Path]:
|
|
201
|
+
return [f for g in OLD_FILE_GLOBS for f in sorted(home.glob(g + ".bak-*")) if _old_generated(f)]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _old_copy(d: Path) -> bool:
|
|
205
|
+
side = d / OLD_SIDECAR
|
|
206
|
+
try:
|
|
207
|
+
want = side.read_text(encoding="utf-8").strip()
|
|
208
|
+
files = sorted((f for f in d.rglob("*") if f.is_file() and f != side and install.installed_file(d, f)),
|
|
209
|
+
key=lambda f: f.relative_to(d).parts)
|
|
210
|
+
h = hashlib.sha256()
|
|
211
|
+
for f in files:
|
|
212
|
+
h.update(f.relative_to(d).as_posix().encode() + b"\0" + f.read_bytes() + b"\0")
|
|
213
|
+
except OSError:
|
|
214
|
+
return False
|
|
215
|
+
return h.hexdigest() == want
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _old_skill_backups() -> list[Path]:
|
|
219
|
+
roots = {paths.agents_skills().parent / "skills-backup", claude_skills().parent / "skills-backup"}
|
|
220
|
+
return [d for r in sorted(roots) if r.is_dir() for d in sorted(r.glob("*.bak-*"))
|
|
221
|
+
if d.is_dir() and not d.is_symlink() and _old_copy(d)]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _hook_files(home: Path) -> list[Path]:
|
|
225
|
+
return [p for p in (home / "hooks.json", claude_settings()) if p.is_file()]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _without_old_hooks(path: Path) -> tuple[dict, bool]:
|
|
229
|
+
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
230
|
+
hooks = data.get("hooks") if isinstance(data, dict) else None
|
|
231
|
+
changed = False
|
|
232
|
+
for event, groups in (hooks.items() if isinstance(hooks, dict) else []):
|
|
233
|
+
if not install.valid_hook_groups(groups):
|
|
234
|
+
continue
|
|
235
|
+
kept = groups
|
|
236
|
+
for status in OLD_STATUSES:
|
|
237
|
+
kept = install._without_status(kept, status)
|
|
238
|
+
if len(kept) != len(groups):
|
|
239
|
+
hooks[event], changed = kept, True
|
|
240
|
+
return data, changed
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def remove_old_generated(home: Path, dry_run: bool = False) -> list[str]:
|
|
244
|
+
p = "would " if dry_run else ""
|
|
245
|
+
lines = []
|
|
246
|
+
for f in _old_files(home):
|
|
247
|
+
verdict = _old_generated(f)
|
|
248
|
+
if verdict is False:
|
|
249
|
+
lines.append(f"left {f}: edited by hand")
|
|
250
|
+
elif verdict:
|
|
251
|
+
if not dry_run:
|
|
252
|
+
f.unlink()
|
|
253
|
+
lines.append(f"{p}delete {f}")
|
|
254
|
+
for f in _old_backups(home):
|
|
255
|
+
if not dry_run:
|
|
256
|
+
f.unlink()
|
|
257
|
+
lines.append(f"{p}delete {f}: a backup of a file the old version wrote")
|
|
258
|
+
for d in _old_skill_backups():
|
|
259
|
+
if not dry_run:
|
|
260
|
+
shutil.rmtree(d)
|
|
261
|
+
lines.append(f"{p}delete {d}: a backup of a skill the old version copied")
|
|
262
|
+
for f in _hook_files(home):
|
|
263
|
+
try:
|
|
264
|
+
data, changed = _without_old_hooks(f)
|
|
265
|
+
except (OSError, ValueError) as e:
|
|
266
|
+
lines.append(f"failed: {f}: {e}")
|
|
267
|
+
continue
|
|
268
|
+
if changed:
|
|
269
|
+
if not dry_run:
|
|
270
|
+
atomic_write(f, (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8"))
|
|
271
|
+
lines.append(f"{p}remove old hook entries from {f}")
|
|
272
|
+
old = old_state(home)
|
|
273
|
+
keep = not dry_run and _old_hooks_path(home)
|
|
274
|
+
for d in (old / "lib", old / "git-hooks"):
|
|
275
|
+
if d.is_dir() and keep:
|
|
276
|
+
lines.append(f"left {d}: global core.hooksPath still points at it")
|
|
277
|
+
elif d.is_dir():
|
|
278
|
+
if not dry_run:
|
|
279
|
+
shutil.rmtree(d)
|
|
280
|
+
lines.append(f"{p}delete {d}")
|
|
281
|
+
gone = {"lib", "git-hooks", "usage.sqlite3"} if dry_run else set()
|
|
282
|
+
if old.is_dir() and all(e.name in gone for e in old.iterdir()):
|
|
283
|
+
if not dry_run:
|
|
284
|
+
old.rmdir()
|
|
285
|
+
lines.append(f"{p}delete {old}")
|
|
286
|
+
return lines
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _sidecars(dirs: list[Path]) -> list[Path]:
|
|
290
|
+
return [f for d in dirs if d.is_dir() for f in sorted(d.glob(f"*/{OLD_SIDECAR}"))]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def move_skill_sidecars(dirs: list[Path], dry_run: bool = False) -> list[str]:
|
|
294
|
+
p = "would " if dry_run else ""
|
|
295
|
+
lines = []
|
|
296
|
+
for old in _sidecars(dirs):
|
|
297
|
+
new = old.with_name(install.SKILL_SIDECAR)
|
|
298
|
+
if new.exists():
|
|
299
|
+
if not dry_run:
|
|
300
|
+
old.unlink()
|
|
301
|
+
lines.append(f"{p}delete {old}: {new} exists")
|
|
302
|
+
else:
|
|
303
|
+
if not dry_run:
|
|
304
|
+
old.rename(new)
|
|
305
|
+
lines.append(f"{p}rename {old} to {new}")
|
|
306
|
+
return lines
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _old_link(path: Path) -> str | None:
|
|
310
|
+
if not path.is_symlink():
|
|
311
|
+
return None
|
|
312
|
+
m = OLD_LINK.search(os.readlink(path))
|
|
313
|
+
if not m or (m[1] == "dev-harness" and m[2] not in SHARED_DEV_SKILLS):
|
|
314
|
+
return None
|
|
315
|
+
return m[2]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def replace_skill_links(core: Path, ov: Overlay, claude_skills: Path, dry_run: bool = False) -> list[str]:
|
|
319
|
+
p = "would " if dry_run else ""
|
|
320
|
+
lines = []
|
|
321
|
+
sources = install.skill_sources(core, ov.root)
|
|
322
|
+
for d in (claude_skills, paths.agents_skills()):
|
|
323
|
+
replaced = {}
|
|
324
|
+
for link in sorted(d.iterdir()) if d.is_dir() else []:
|
|
325
|
+
name = _old_link(link)
|
|
326
|
+
if name is None:
|
|
327
|
+
continue
|
|
328
|
+
if name not in sources:
|
|
329
|
+
if not dry_run:
|
|
330
|
+
link.unlink()
|
|
331
|
+
lines.append(f"{p}remove {link}: {name} is no longer in the core")
|
|
332
|
+
continue
|
|
333
|
+
lines.append(f"{p}replace the link {link} with a copy")
|
|
334
|
+
replaced[link] = sources[name]
|
|
335
|
+
if not replaced or dry_run:
|
|
336
|
+
continue
|
|
337
|
+
owned = {n: src for n, src in sources.items()
|
|
338
|
+
if not (d / n).is_symlink() and (d / n / install.SKILL_SIDECAR).is_file()}
|
|
339
|
+
for link in replaced:
|
|
340
|
+
link.unlink()
|
|
341
|
+
lines += install.install_skills({**owned, **{link.name: src for link, src in replaced.items()}}, d)
|
|
342
|
+
return lines
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@functools.cache
|
|
346
|
+
def _bootstrap(core: Path):
|
|
347
|
+
spec = importlib.util.spec_from_file_location("codejig_bootstrap", core / "bootstrap.py")
|
|
348
|
+
module = importlib.util.module_from_spec(spec)
|
|
349
|
+
spec.loader.exec_module(module)
|
|
350
|
+
return module
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _tag(core: Path) -> str:
|
|
354
|
+
r = _git("-C", str(core), "describe", "--tags", "--always")
|
|
355
|
+
if r and r.returncode == 0 and r.stdout.strip():
|
|
356
|
+
return r.stdout.strip()
|
|
357
|
+
try:
|
|
358
|
+
return (core / "VERSION").read_text(encoding="utf-8").split()[0]
|
|
359
|
+
except (OSError, IndexError):
|
|
360
|
+
return "checkout"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _hand_written(text: str) -> bool:
|
|
364
|
+
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
365
|
+
return ("CODEX_HARNESS_OVERLAY" in text and "-m harness" in text
|
|
366
|
+
and all(line.startswith(("#!", "export ", "PYTHONPATH", "exec ")) for line in lines))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _old_launchers(bs) -> list[Path]:
|
|
370
|
+
bins = {bs.bin_dir(None, bs.data_dir(None)), bs.bin_dir(None, bs.data_dir(None).parent / "codex-harness")}
|
|
371
|
+
return [b / name for b in sorted(bins) for name in ("harness", "harness.cmd") if (b / name).is_file()]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _known_launcher(f: Path) -> bool | None:
|
|
375
|
+
try:
|
|
376
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
377
|
+
except OSError:
|
|
378
|
+
return None
|
|
379
|
+
return OLD_LAUNCHER_MARK in text or _hand_written(text)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def write_launchers(core: Path, overlay: Path, dry_run: bool = False) -> list[str]:
|
|
383
|
+
p = "would " if dry_run else ""
|
|
384
|
+
bs = _bootstrap(core)
|
|
385
|
+
lines = []
|
|
386
|
+
for f in _old_launchers(bs):
|
|
387
|
+
known = _known_launcher(f)
|
|
388
|
+
if known is None:
|
|
389
|
+
lines.append(f"delete {f} yourself: cannot read it")
|
|
390
|
+
elif known:
|
|
391
|
+
if not dry_run:
|
|
392
|
+
f.unlink()
|
|
393
|
+
lines.append(f"{p}delete {f}")
|
|
394
|
+
else:
|
|
395
|
+
lines.append(f"left {f}: not a code-jig launcher")
|
|
396
|
+
bins = bs.bin_dir(None, bs.data_dir(None))
|
|
397
|
+
try:
|
|
398
|
+
if dry_run:
|
|
399
|
+
for f in bs.launcher_paths(bins):
|
|
400
|
+
bs.check_launcher(f)
|
|
401
|
+
return [*lines, *(f"would write {f}" for f in bs.launcher_paths(bins))]
|
|
402
|
+
written = bs.write_launcher(bins, install.interpreter(), core, overlay, _tag(core))
|
|
403
|
+
except bs.Refused as e:
|
|
404
|
+
return [*lines, f"refused: {e}"]
|
|
405
|
+
return [*lines, *(f"wrote {f}" for f in written)]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _scan_files(home: Path) -> list[Path]:
|
|
409
|
+
config = home / ".config"
|
|
410
|
+
files = [home / ".bashrc", home / ".zshrc", home / ".profile", *sorted(config.glob("environment.d/*"))]
|
|
411
|
+
for ps in ("PowerShell", "WindowsPowerShell"):
|
|
412
|
+
files += sorted((home / "Documents" / ps).glob("*profile.ps1"))
|
|
413
|
+
for depth in ("*", "*/*", "*/*/*"):
|
|
414
|
+
for ext in ("json", "toml"):
|
|
415
|
+
files += sorted(config.glob(f"{depth}.{ext}"))
|
|
416
|
+
return list(dict.fromkeys(files))
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def leftovers(home: Path) -> list[str]:
|
|
420
|
+
lines = []
|
|
421
|
+
for f in _scan_files(home):
|
|
422
|
+
try:
|
|
423
|
+
if not f.is_file() or f.stat().st_size > 1_000_000:
|
|
424
|
+
continue
|
|
425
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
426
|
+
except OSError:
|
|
427
|
+
continue
|
|
428
|
+
lines += [f"still names old paths: {f}:{n}" for n, line in enumerate(text.splitlines(), 1)
|
|
429
|
+
if any(name in line for name in OLD_NAMES)]
|
|
430
|
+
return lines
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _old_keys(includes: bool) -> list[tuple[str, str]]:
|
|
434
|
+
r = _git("config", "--global", *(["--includes"] if includes else []), "--show-origin", "-z",
|
|
435
|
+
"--get-regexp", r"^codexharness\.")
|
|
436
|
+
parts = r.stdout.split("\0")[:-1] if r and r.returncode == 0 else []
|
|
437
|
+
return [(origin, entry.split("\n")[0]) for origin, entry in zip(parts[::2], parts[1::2])]
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _new_gate_active() -> bool:
|
|
441
|
+
return install._same_path(install._git_config("--get", "core.hooksPath") or "", paths.state_dir() / "git-hooks")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def pending(home: Path) -> list[str]:
|
|
445
|
+
lines = []
|
|
446
|
+
old = old_state(home)
|
|
447
|
+
if old.exists():
|
|
448
|
+
lines.append(f"{old} still exists")
|
|
449
|
+
current = _old_hooks_path(home)
|
|
450
|
+
if current:
|
|
451
|
+
lines.append(f"global core.hooksPath is {current}")
|
|
452
|
+
own = {origin for origin, _ in _old_keys(False)}
|
|
453
|
+
for origin, key in _old_keys(True):
|
|
454
|
+
lines.append(f"global git key {key} is set" if origin in own
|
|
455
|
+
else f"{key} in {origin.removeprefix('file:')}: edit by hand")
|
|
456
|
+
lines += [f"{f} was written by the old version" for f in [*_old_files(home), *_old_backups(home)] if _old_generated(f)]
|
|
457
|
+
lines += [f"{d} is a backup of an old skill copy" for d in _old_skill_backups()]
|
|
458
|
+
for f in _hook_files(home):
|
|
459
|
+
try:
|
|
460
|
+
if _without_old_hooks(f)[1]:
|
|
461
|
+
lines.append(f"{f} has old hook entries")
|
|
462
|
+
except (OSError, ValueError):
|
|
463
|
+
pass
|
|
464
|
+
lines += [f"{f} is an old skill sidecar" for f in _sidecars([paths.agents_skills(), claude_skills()])]
|
|
465
|
+
return lines
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _stopped(lines: list[str]) -> bool:
|
|
469
|
+
return any(line.startswith(("refused:", "failed:")) for line in lines)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def run(core: Path, ov: Overlay, home: Path, repos: list[Path], dry_run: bool) -> list[str]:
|
|
473
|
+
settings = claude_settings()
|
|
474
|
+
try:
|
|
475
|
+
install.plan(core, ov, home, settings if settings.is_file() else None)
|
|
476
|
+
install.skill_sources(core, ov.root)
|
|
477
|
+
except (ValueError, OSError) as e:
|
|
478
|
+
return [f"failed: code-jig install would fail: {e}", "nothing was changed; fix it and rerun"]
|
|
479
|
+
lines = [*move_db(home, dry_run), *move_git_config(repos, dry_run)]
|
|
480
|
+
if _stopped(lines):
|
|
481
|
+
return [*lines, SKIPPED]
|
|
482
|
+
lines += release_hooks_path(home, dry_run)
|
|
483
|
+
if _stopped(lines):
|
|
484
|
+
return [*lines, SKIPPED]
|
|
485
|
+
lines += [*remove_old_generated(home, dry_run),
|
|
486
|
+
*move_skill_sidecars([paths.agents_skills(), claude_skills()], dry_run),
|
|
487
|
+
*replace_skill_links(core, ov, claude_skills(), dry_run)]
|
|
488
|
+
bs = _bootstrap(core)
|
|
489
|
+
done = (all(f.is_file() for f in bs.launcher_paths(bs.bin_dir(None, bs.data_dir(None))))
|
|
490
|
+
and not any(_known_launcher(f) for f in _old_launchers(bs))
|
|
491
|
+
and (not ov.git_gate or _new_gate_active()))
|
|
492
|
+
if not lines and done:
|
|
493
|
+
return ["nothing to do", *leftovers(Path.home())]
|
|
494
|
+
if dry_run:
|
|
495
|
+
lines.append("would run code-jig install")
|
|
496
|
+
else:
|
|
497
|
+
try:
|
|
498
|
+
lines += install.run(core, ov, home)
|
|
499
|
+
except (ValueError, OSError) as e:
|
|
500
|
+
return [*lines, f"failed: code-jig install: {e}", *([GATE_OFF] if ov.git_gate and not _new_gate_active() else [])]
|
|
501
|
+
return [*lines, *write_launchers(core, ov.root, dry_run), *leftovers(Path.home())]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def cli(args) -> int:
|
|
505
|
+
print(NOTICE)
|
|
506
|
+
try:
|
|
507
|
+
ov = load(paths.overlay_dir(args.overlay))
|
|
508
|
+
except ValueError as e:
|
|
509
|
+
print(f"code-jig: {e}", file=sys.stderr)
|
|
510
|
+
return 2
|
|
511
|
+
repos = [Path(r) for r in args.repos] if args.repos else default_repos()
|
|
512
|
+
lines = run(paths.CORE, ov, paths.codex_home(args.codex_home), repos, args.dry_run)
|
|
513
|
+
for line in lines:
|
|
514
|
+
print(line)
|
|
515
|
+
return 1 if any(line.startswith(("refused:", "failed:")) for line in lines) else 0
|
codejig/overlay.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
REQUIRED = ("core_version", "core_writable")
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Overlay:
|
|
10
|
+
root: Path
|
|
11
|
+
core_version: str
|
|
12
|
+
core_writable: bool
|
|
13
|
+
auto_compact_token_limit: int | None = None
|
|
14
|
+
git_gate: bool = False
|
|
15
|
+
prose_gate: bool = False
|
|
16
|
+
push_cleanup: bool | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _flags(path: Path, data: dict, compact) -> dict:
|
|
20
|
+
if compact is not None and (isinstance(compact, bool) or not isinstance(compact, int) or compact <= 0):
|
|
21
|
+
raise ValueError(f"{path}: auto_compact_token_limit must be a positive integer")
|
|
22
|
+
flags = {k: data.get(k, False) for k in ("git_gate", "prose_gate")}
|
|
23
|
+
flags["push_cleanup"] = data.get("push_cleanup")
|
|
24
|
+
bad = [k for k, v in flags.items() if not isinstance(v, bool) and not (k == "push_cleanup" and v is None)]
|
|
25
|
+
if bad:
|
|
26
|
+
raise ValueError(f"{path}: {', '.join(bad)} must be true or false")
|
|
27
|
+
return flags
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load(root: Path) -> Overlay:
|
|
31
|
+
path = root / "overlay.toml"
|
|
32
|
+
try:
|
|
33
|
+
data = tomllib.loads(path.read_text(encoding="utf-8-sig"))
|
|
34
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as e:
|
|
35
|
+
raise ValueError(f"{path}: {e}") from e
|
|
36
|
+
missing = [k for k in REQUIRED if k not in data]
|
|
37
|
+
if missing:
|
|
38
|
+
raise ValueError(f"{path}: missing {', '.join(missing)}")
|
|
39
|
+
if not isinstance(data["core_writable"], bool):
|
|
40
|
+
raise ValueError(f"{path}: core_writable must be true or false")
|
|
41
|
+
compact = data.get("auto_compact_token_limit")
|
|
42
|
+
flags = _flags(path, data, compact)
|
|
43
|
+
return Overlay(root, str(data["core_version"]), data["core_writable"], compact, **flags)
|
codejig/paths.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
CODE = Path(__file__).resolve().parent
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def locate_core(code: Path) -> Path:
|
|
8
|
+
return code / "_core" if (code / "_core").is_dir() else code.parent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
CORE = locate_core(CODE)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def codex_home(arg: str | None) -> Path:
|
|
15
|
+
return Path(arg or os.environ.get("CODEX_HOME") or Path.home() / ".codex").expanduser()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def state_dir() -> Path:
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
return Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "code-jig"
|
|
21
|
+
return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "code-jig"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def overlay_dir(arg: str | None) -> Path:
|
|
25
|
+
value = arg or os.environ.get("CODE_JIG_OVERLAY")
|
|
26
|
+
if not value:
|
|
27
|
+
raise SystemExit("code-jig: no overlay; pass --overlay PATH or set CODE_JIG_OVERLAY")
|
|
28
|
+
root = Path(value).expanduser().absolute()
|
|
29
|
+
if not (root / "overlay.toml").is_file():
|
|
30
|
+
raise SystemExit(f"code-jig: {root / 'overlay.toml'} not found")
|
|
31
|
+
return root
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def agents_skills() -> Path:
|
|
35
|
+
return Path(os.environ.get("CODE_JIG_SKILLS_DIR") or Path.home() / ".agents" / "skills").expanduser()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def claude_code_settings() -> Path | None:
|
|
39
|
+
path = Path.home() / ".claude" / "settings.json"
|
|
40
|
+
return path if path.is_file() else None
|