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.
Files changed (52) hide show
  1. code_jig-0.12.0.dist-info/METADATA +154 -0
  2. code_jig-0.12.0.dist-info/RECORD +52 -0
  3. code_jig-0.12.0.dist-info/WHEEL +4 -0
  4. code_jig-0.12.0.dist-info/entry_points.txt +2 -0
  5. code_jig-0.12.0.dist-info/licenses/LICENSE +202 -0
  6. codejig/__init__.py +1 -0
  7. codejig/__main__.py +106 -0
  8. codejig/_core/VERSION +2 -0
  9. codejig/_core/bench/requirements.toml +4 -0
  10. codejig/_core/core/AGENTS.core.md +9 -0
  11. codejig/_core/core/agents/explorer.toml +24 -0
  12. codejig/_core/core/agents/judge.toml +19 -0
  13. codejig/_core/core/agents/reviewer.toml +10 -0
  14. codejig/_core/core/tiers.toml +4 -0
  15. codejig/_core/skills/bug-hunt/SKILL.md +131 -0
  16. codejig/_core/skills/code-analysis/SKILL.md +72 -0
  17. codejig/_core/skills/codegen/SKILL.md +46 -0
  18. codejig/_core/skills/cross-review/SKILL.md +32 -0
  19. codejig/_core/skills/grilling/SKILL.md +60 -0
  20. codejig/_core/skills/hld-trace/SKILL.md +69 -0
  21. codejig/_core/skills/improve/SKILL.md +38 -0
  22. codejig/_core/skills/redacao/SKILL.md +114 -0
  23. codejig/_core/skills/redacao/checar.py +75 -0
  24. codejig/_core/skills/redacao/reference.md +118 -0
  25. codejig/_core/skills/regression-tests/SKILL.md +53 -0
  26. codejig/_core/skills/remove-ai-attribution/SKILL.md +33 -0
  27. codejig/_core/skills/skill-validate/SKILL.md +58 -0
  28. codejig/_core/skills/web-consensus/SKILL.md +55 -0
  29. codejig/analyze.py +499 -0
  30. codejig/atomicwrite.py +49 -0
  31. codejig/collect.py +311 -0
  32. codejig/doctor.py +370 -0
  33. codejig/evidence.py +418 -0
  34. codejig/feedback.py +165 -0
  35. codejig/gitgate.py +278 -0
  36. codejig/hooks.py +64 -0
  37. codejig/humanize.py +206 -0
  38. codejig/install.py +381 -0
  39. codejig/managed.py +64 -0
  40. codejig/migrate.py +515 -0
  41. codejig/overlay.py +43 -0
  42. codejig/paths.py +40 -0
  43. codejig/probe.py +136 -0
  44. codejig/review.py +73 -0
  45. codejig/scrub.py +255 -0
  46. codejig/skillcheck.py +400 -0
  47. codejig/skilllint.py +99 -0
  48. codejig/sources.py +248 -0
  49. codejig/tiers.py +63 -0
  50. codejig/tokens.py +118 -0
  51. codejig/trace.py +137 -0
  52. 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