@r1z4x/tezgah 1.13.0 → 1.14.0
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.
- package/CHANGELOG.md +175 -1
- package/MANIFEST +18 -0
- package/NOTICE +152 -0
- package/README.de.md +41 -20
- package/README.es.md +39 -19
- package/README.fr.md +41 -20
- package/README.md +15 -8
- package/README.tr.md +39 -21
- package/bin/codegen +9 -2
- package/bin/consult +6 -0
- package/bin/tezgah-adhd +3 -0
- package/bin/tezgah-agents +3 -0
- package/bin/tezgah-capture +3 -0
- package/bin/tezgah-design +329 -54
- package/bin/tezgah-docs +157 -41
- package/bin/tezgah-dsh +1 -1
- package/bin/tezgah-gate +7 -2
- package/bin/tezgah-import-ai-research +16 -4
- package/bin/tezgah-index +3 -0
- package/bin/tezgah-lessons +13 -4
- package/bin/tezgah-mcp +4 -0
- package/bin/tezgah-migrate +3 -0
- package/bin/tezgah-ocr-review +2 -1
- package/bin/tezgah-optimize +4 -1
- package/bin/tezgah-pony +3 -0
- package/bin/tezgah-research +12 -6
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +596 -157
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +3 -0
- package/bin/tezgah.js +10 -3
- package/commands/adhd.md +1 -1
- package/docs/README.md +19 -8
- package/docs/architecture.md +2 -2
- package/docs/citations-baseline.json +11 -17
- package/docs/clarity-baseline.json +3 -3
- package/docs/contract.md +77 -31
- package/docs/environment.md +58 -24
- package/docs/evidence.md +49 -19
- package/docs/gate.md +71 -21
- package/docs/hosts.md +65 -52
- package/docs/layers.md +1 -1
- package/docs/operations.md +67 -24
- package/docs/research.md +1 -1
- package/docs/skills.md +31 -8
- package/docs/status-line.md +33 -23
- package/docs/testing.md +48 -17
- package/docs/vision.md +6 -2
- package/hooks/lang/en.json +5 -2
- package/hooks/lang/tr.json +3 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +1 -1
- package/hooks/tezgah_context.py +40 -23
- package/hooks/tezgah_gate.py +404 -106
- package/hooks/tezgah_integrity.py +367 -97
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +17 -6
- package/hooks/tezgah_policy.py +13 -5
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stopspec.py +2 -1
- package/hosts/amp/hook.py +1 -1
- package/hosts/amp/tezgah-plugin.ts.in +13 -3
- package/hosts/antigravity/hook.py +1 -1
- package/hosts/codex/hook.py +1 -1
- package/hosts/commandcode/hook.py +1 -1
- package/hosts/copilot/hook.py +1 -1
- package/hosts/cursor/hook.py +2 -1
- package/hosts/droid/hook.py +1 -1
- package/hosts/gemini/hook.py +1 -1
- package/hosts/goose/hook.py +1 -1
- package/hosts/omp/hook.py +1 -1
- package/hosts/omp/tezgah-hook.ts.in +127 -35
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +1 -1
- package/output-styles/tezgah.md +1 -1
- package/package.json +6 -3
- package/packaging/build.sh +4 -4
- package/packaging/install.sh +11 -0
- package/packaging/upgrade.sh +3 -0
- package/skills/ai-research/LICENSE +21 -0
- package/skills/design-contract/SKILL.md +27 -6
- package/skills/design-library/LICENSE +21 -0
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +2 -2
- package/third_party/open-code-review/LICENSE +201 -0
- package/third_party/open-code-review/README.md +15 -0
- package/third_party/open-code-review/prompts/main_task_system.md +26 -0
- package/third_party/open-code-review/prompts/review_filter_task_system.md +13 -0
- package/workflows/graph-map.js +14 -5
package/bin/tezgah-setup
CHANGED
|
@@ -262,6 +262,11 @@ SKILL_ROUTER = os.path.join(tp.CONFIG_DIR, "opencode-skills.md")
|
|
|
262
262
|
# Where `link()` records the entries it had to materialise instead of
|
|
263
263
|
# symlinking; see materialised_paths() for why the fact has to be written down.
|
|
264
264
|
MATERIALISED = os.path.join(tp.CONFIG_DIR, "materialised")
|
|
265
|
+
# What an install found in each host file it edited, so an uninstall puts back
|
|
266
|
+
# exactly that: {path: {"created": bool, "<key>": prior value | ABSENT}}, a key
|
|
267
|
+
# being a "/"-joined path into the file's JSON object. See remember_prior().
|
|
268
|
+
PRIOR = os.path.join(tp.CONFIG_DIR, "install-prior.json")
|
|
269
|
+
ABSENT = "absent"
|
|
265
270
|
# Every kill switch the code reads (`off("...")` under hooks/ and bin/;
|
|
266
271
|
# `bin/tezgah-docs --citations` checks the two agree). The canonical
|
|
267
272
|
# copies die with ~/.config/tezgah; the pre-multi-host setup wrote them to
|
|
@@ -280,7 +285,8 @@ OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "design-off", "docs
|
|
|
280
285
|
# moved aside - the only copy of them on disk.
|
|
281
286
|
GENERATED_STATE = frozenset((
|
|
282
287
|
"bin", "config.json", "contract.sha256", "contract.sha256.lock",
|
|
283
|
-
"contract.sha256.tmp", "install.
|
|
288
|
+
"contract.sha256.tmp", "install-prior.json", "install-prior.json.tmp",
|
|
289
|
+
"install.log", "materialised",
|
|
284
290
|
"opencode-contract.md", "opencode-skills.md", "opencode-skills.full.md",
|
|
285
291
|
"ponytail.level", "state"))
|
|
286
292
|
# The predecessor harness's marker in an AGENTS.md under a root: the block tells
|
|
@@ -428,7 +434,20 @@ OK, NO = " ok ", " MISS "
|
|
|
428
434
|
|
|
429
435
|
|
|
430
436
|
def mkdirp(p):
|
|
437
|
+
"""makedirs, recording each directory level it creates under HOME (outside
|
|
438
|
+
tezgah's own config dir) so uninstall removes it once empty (OQ-13)."""
|
|
439
|
+
made, d = [], os.path.abspath(p)
|
|
440
|
+
while not os.path.lexists(d) and d != os.path.dirname(d):
|
|
441
|
+
made.append(d)
|
|
442
|
+
d = os.path.dirname(d)
|
|
431
443
|
os.makedirs(p, exist_ok=True)
|
|
444
|
+
made = [d for d in made if not UNINSTALLING and d.startswith(tp.HOME + os.sep)
|
|
445
|
+
and d != tp.CONFIG_DIR and not d.startswith(tp.CONFIG_DIR + os.sep)]
|
|
446
|
+
if made:
|
|
447
|
+
store = read_json(PRIOR, {})
|
|
448
|
+
for d in made:
|
|
449
|
+
store[d + os.sep] = {"created": True}
|
|
450
|
+
_prior_save(store)
|
|
432
451
|
|
|
433
452
|
|
|
434
453
|
def expand(p):
|
|
@@ -657,6 +676,132 @@ def record_materialised(path, add=True):
|
|
|
657
676
|
pass
|
|
658
677
|
|
|
659
678
|
|
|
679
|
+
def _prior_save(store):
|
|
680
|
+
"""Write the install-prior record whole (temp file, then replace); an
|
|
681
|
+
emptied record is removed, so a finished uninstall leaves no file."""
|
|
682
|
+
try:
|
|
683
|
+
if not store:
|
|
684
|
+
if os.path.exists(PRIOR):
|
|
685
|
+
os.remove(PRIOR)
|
|
686
|
+
return
|
|
687
|
+
mkdirp(tp.CONFIG_DIR)
|
|
688
|
+
with open(PRIOR + ".tmp", "w", encoding="utf-8") as fh:
|
|
689
|
+
# no sort_keys: a recorded value comes back in its own key order
|
|
690
|
+
fh.write(json.dumps(store, indent=2, ensure_ascii=False) + "\n")
|
|
691
|
+
os.replace(PRIOR + ".tmp", PRIOR)
|
|
692
|
+
except OSError:
|
|
693
|
+
pass
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _key_chain(key):
|
|
697
|
+
""""a/b/c" -> ["a", "a/b", "a/b/c"]: the key and every parent level."""
|
|
698
|
+
parts = key.split("/")
|
|
699
|
+
return ["/".join(parts[:i]) for i in range(1, len(parts) + 1)]
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _prior_entry(store, path):
|
|
703
|
+
"""The record of one host file, made empty when absent or not an object:
|
|
704
|
+
a hand-edited or damaged entry reads as no record, never a traceback."""
|
|
705
|
+
entry = store.get(path)
|
|
706
|
+
if not isinstance(entry, dict):
|
|
707
|
+
entry = store[path] = {}
|
|
708
|
+
return entry
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def note_created(path):
|
|
712
|
+
"""Record, the first time tezgah writes a host file, whether the file was
|
|
713
|
+
there before. The first answer stands: a later write finds tezgah's own file.
|
|
714
|
+
A file already carrying tezgah's name was written by an install older than
|
|
715
|
+
the record, so it gets none: its uninstall does what it always did."""
|
|
716
|
+
real = os.path.abspath(path)
|
|
717
|
+
if UNINSTALLING or real.startswith(tp.CONFIG_DIR + os.sep) \
|
|
718
|
+
or not real.startswith(tp.HOME + os.sep):
|
|
719
|
+
return
|
|
720
|
+
store = read_json(PRIOR, {})
|
|
721
|
+
entry = _prior_entry(store, path)
|
|
722
|
+
if "created" in entry:
|
|
723
|
+
return
|
|
724
|
+
if os.path.lexists(path):
|
|
725
|
+
try:
|
|
726
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
727
|
+
if "tezgah" in fh.read():
|
|
728
|
+
return
|
|
729
|
+
except OSError:
|
|
730
|
+
pass
|
|
731
|
+
entry["created"] = not os.path.lexists(path)
|
|
732
|
+
_prior_save(store)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def remember_prior(path, data, key):
|
|
736
|
+
"""Record what `data` - the object read from `path` before tezgah edits
|
|
737
|
+
it - holds at `key`, once: a later install finds tezgah's own value there,
|
|
738
|
+
so the first answer is the user's. A missing level is recorded as ABSENT at
|
|
739
|
+
the first level that is missing, which tells restore_prior() the parents
|
|
740
|
+
tezgah created too."""
|
|
741
|
+
store = read_json(PRIOR, {})
|
|
742
|
+
entry = _prior_entry(store, path)
|
|
743
|
+
chain = _key_chain(key)
|
|
744
|
+
if any(k in entry for k in chain):
|
|
745
|
+
return
|
|
746
|
+
node = data
|
|
747
|
+
for k in chain:
|
|
748
|
+
part = k.rsplit("/", 1)[-1]
|
|
749
|
+
if not isinstance(node, dict) or part not in node:
|
|
750
|
+
entry[k] = ABSENT
|
|
751
|
+
break
|
|
752
|
+
node = node[part]
|
|
753
|
+
else:
|
|
754
|
+
entry[key] = node
|
|
755
|
+
_prior_save(store)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def restore_prior(path, data, key, keep_unrecorded=False):
|
|
759
|
+
"""Put `key` of `data` back to what the install found: the user's value, or
|
|
760
|
+
no key at all, with every parent level the install created and left empty.
|
|
761
|
+
An install older than the record left none: the key goes with its emptied
|
|
762
|
+
parents (what uninstall always did), or stays with `keep_unrecorded`.
|
|
763
|
+
Returns True when `data` changed; the record forgets the key either way."""
|
|
764
|
+
store = read_json(PRIOR, {})
|
|
765
|
+
entry = _prior_entry(store, path)
|
|
766
|
+
chain = _key_chain(key)
|
|
767
|
+
rec = next((k for k in chain if k in entry), None)
|
|
768
|
+
parts = key.split("/")
|
|
769
|
+
nodes = [data]
|
|
770
|
+
for part in parts[:-1]:
|
|
771
|
+
nodes.append(nodes[-1].get(part) if isinstance(nodes[-1], dict) else None)
|
|
772
|
+
before = json.dumps(data, sort_keys=True)
|
|
773
|
+
if isinstance(nodes[-1], dict) and not (rec is None and keep_unrecorded):
|
|
774
|
+
if rec == key and entry[rec] != ABSENT:
|
|
775
|
+
nodes[-1][parts[-1]] = entry[rec]
|
|
776
|
+
else:
|
|
777
|
+
nodes[-1].pop(parts[-1], None)
|
|
778
|
+
floor = len(rec.split("/")) if rec else 1
|
|
779
|
+
for depth in range(len(parts) - 1, 0, -1):
|
|
780
|
+
if depth < floor or nodes[depth]:
|
|
781
|
+
break
|
|
782
|
+
nodes[depth - 1].pop(parts[depth - 1])
|
|
783
|
+
if rec is not None:
|
|
784
|
+
for k in chain:
|
|
785
|
+
entry.pop(k, None)
|
|
786
|
+
_prior_save(store)
|
|
787
|
+
return json.dumps(data, sort_keys=True) != before
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def prior_created(path):
|
|
791
|
+
"""True when the record says tezgah created this host file, False when it
|
|
792
|
+
says the file was there, None with no record (an install older than it)."""
|
|
793
|
+
entry = read_json(PRIOR, {}).get(path)
|
|
794
|
+
created = entry.get("created") if isinstance(entry, dict) else None
|
|
795
|
+
return created if isinstance(created, bool) else None
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def forget_prior(path):
|
|
799
|
+
"""Drop the record of one host file once its uninstall is done."""
|
|
800
|
+
store = read_json(PRIOR, {})
|
|
801
|
+
if store.pop(path, None) is not None:
|
|
802
|
+
_prior_save(store)
|
|
803
|
+
|
|
804
|
+
|
|
660
805
|
def is_tezgah_copy(path):
|
|
661
806
|
"""True when `path` is a real entry this installer materialised where a
|
|
662
807
|
symlink was refused. A same-named file tezgah never wrote is the user's."""
|
|
@@ -745,39 +890,92 @@ def read_json(path, default):
|
|
|
745
890
|
return data
|
|
746
891
|
|
|
747
892
|
|
|
748
|
-
# Every host
|
|
893
|
+
# Every host file this run refused to rewrite; a non-empty list fails it.
|
|
749
894
|
REFUSED = []
|
|
750
895
|
|
|
751
896
|
|
|
752
|
-
def
|
|
897
|
+
def read_failure(exc):
|
|
898
|
+
"""The cause a host-file read failed with, in the words a refusal names."""
|
|
899
|
+
if isinstance(exc, UnicodeDecodeError):
|
|
900
|
+
return "not UTF-8 text"
|
|
901
|
+
if isinstance(exc, OSError):
|
|
902
|
+
return "cannot read it: %s" % (exc.strerror or exc)
|
|
903
|
+
return "not valid JSON: %s" % exc
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def refuse(path, why, rerun="--install"):
|
|
907
|
+
"""Name a host file tezgah will not touch and why; an install records it in
|
|
908
|
+
REFUSED, which fails the run."""
|
|
909
|
+
print("%s %s (%s; left untouched - fix it, then re-run %s)" % (NO, path, why, rerun))
|
|
910
|
+
if rerun == "--install":
|
|
911
|
+
REFUSED.append(path)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def read_config(path, empty, comments=False, rerun="--install"):
|
|
753
915
|
"""A host config tezgah is about to rewrite: `empty` when the file is absent,
|
|
754
916
|
its object when it parses as one, None otherwise - named, recorded in
|
|
755
917
|
REFUSED, and never written, because a write would replace the user's file
|
|
756
|
-
with tezgah's default (a trailing comma once cost a whole opencode.json).
|
|
918
|
+
with tezgah's default (a trailing comma once cost a whole opencode.json).
|
|
919
|
+
With `comments` (a host that reads JSON with comments) a file holding
|
|
920
|
+
comments is valid as it is: named and left untouched, not refused."""
|
|
757
921
|
try:
|
|
758
922
|
with open(path, encoding="utf-8") as fh:
|
|
759
|
-
|
|
923
|
+
text = fh.read()
|
|
924
|
+
data = json.loads(text)
|
|
760
925
|
except FileNotFoundError:
|
|
761
926
|
return empty
|
|
762
927
|
except (OSError, ValueError) as exc:
|
|
763
|
-
|
|
928
|
+
if comments and not isinstance(exc, (OSError, UnicodeDecodeError)) \
|
|
929
|
+
and jsonc_loads(text) is not None:
|
|
930
|
+
print("%s %s holds comments - left untouched" % (NO, path))
|
|
931
|
+
return None
|
|
932
|
+
why = read_failure(exc)
|
|
764
933
|
else:
|
|
765
934
|
if isinstance(data, dict):
|
|
766
935
|
return data
|
|
767
936
|
why = "not a JSON object: the top level is %s" % type(data).__name__
|
|
768
|
-
|
|
769
|
-
% (NO, path, why))
|
|
770
|
-
REFUSED.append(path)
|
|
937
|
+
refuse(path, why, rerun)
|
|
771
938
|
return None
|
|
772
939
|
|
|
773
940
|
|
|
941
|
+
def _json_layout(path):
|
|
942
|
+
"""(indent, separators, final newline) of the JSON file at `path`, so a
|
|
943
|
+
rewrite keeps the user's own layout: a one-line file stays one line (a
|
|
944
|
+
minified one stays minified), a 4-space or tab indent stays that. A new
|
|
945
|
+
file, `{}` or an unreadable one gets 2 spaces."""
|
|
946
|
+
try:
|
|
947
|
+
with open(path, encoding="utf-8") as fh:
|
|
948
|
+
text = fh.read()
|
|
949
|
+
except (OSError, ValueError):
|
|
950
|
+
return 2, None, True
|
|
951
|
+
body = text.strip()
|
|
952
|
+
if body in ("", "{}", "[]"):
|
|
953
|
+
return 2, None, True
|
|
954
|
+
m = re.search(r"\n([ \t]*)\S", body)
|
|
955
|
+
if m:
|
|
956
|
+
return m.group(1), None, text.endswith("\n")
|
|
957
|
+
# ponytail: a string value holding ": " misreads a minified file as spaced
|
|
958
|
+
tight = not re.search(r"[:,] ", body)
|
|
959
|
+
return None, ((",", ":") if tight else None), text.endswith("\n")
|
|
960
|
+
|
|
961
|
+
|
|
774
962
|
def write_json(path, data):
|
|
775
|
-
"""Write JSON, skipping the write when the bytes
|
|
963
|
+
"""Write JSON in the file's own layout, skipping the write when the bytes
|
|
964
|
+
are already there.
|
|
776
965
|
|
|
777
966
|
An unchanged write is not free: it moves a host file's mtime and lays down a
|
|
778
967
|
`.tezgah-bak`, so a command whose whole point is idempotence (a repeated
|
|
779
|
-
--disable) would report a change it never made.
|
|
780
|
-
|
|
968
|
+
--disable) would report a change it never made. The first write of a host
|
|
969
|
+
file records whether tezgah created it (note_created). Returns True when it
|
|
970
|
+
wrote."""
|
|
971
|
+
note_created(path)
|
|
972
|
+
# ponytail: one indent for the whole file - an inline `[1, 2]` or
|
|
973
|
+
# `{"a": 1}` inside an indented file comes back expanded (same data);
|
|
974
|
+
# keeping it would need a layout-preserving JSON writer
|
|
975
|
+
indent, seps, newline = _json_layout(path)
|
|
976
|
+
return write_text(path, json.dumps(data, indent=indent, separators=seps,
|
|
977
|
+
ensure_ascii=False)
|
|
978
|
+
+ ("\n" if newline else ""))
|
|
781
979
|
|
|
782
980
|
|
|
783
981
|
def write_text(path, text):
|
|
@@ -926,6 +1124,7 @@ def set_managed_block(path, block, start="# tezgah:start", end="# tezgah:end"):
|
|
|
926
1124
|
out.pop()
|
|
927
1125
|
if not out:
|
|
928
1126
|
out = ["# dsh home patch layer - tezgah-setup manages the marked block below"]
|
|
1127
|
+
note_created(path)
|
|
929
1128
|
mkdirp(os.path.dirname(path))
|
|
930
1129
|
text = "\n".join(out) + "\n" + start + " (managed by tezgah-setup; do not edit)\n" \
|
|
931
1130
|
+ block.strip() + "\n" + end + "\n"
|
|
@@ -1189,7 +1388,13 @@ def install_common(new_roots, hosts):
|
|
|
1189
1388
|
# older one, so an upgrade changes no one's reply language
|
|
1190
1389
|
cfg["language"] = tp.env_language() if first else "tr"
|
|
1191
1390
|
if own:
|
|
1192
|
-
|
|
1391
|
+
try:
|
|
1392
|
+
write_json(tp.CONFIG, cfg)
|
|
1393
|
+
except OSError as exc:
|
|
1394
|
+
# a HOME the install cannot write: every later step would fail the
|
|
1395
|
+
# same way, so the run stops here and names the path (B-100)
|
|
1396
|
+
sys.exit("%s cannot write %s: %s"
|
|
1397
|
+
% (NO, exc.filename or tp.CONFIG, exc.strerror or exc))
|
|
1193
1398
|
print("%s %s roots=%s" % (OK, tp.CONFIG, cfg["roots"]))
|
|
1194
1399
|
for name, src in [
|
|
1195
1400
|
("consult", os.path.join(HERE, "bin", "consult")),
|
|
@@ -1202,6 +1407,9 @@ def install_common(new_roots, hosts):
|
|
|
1202
1407
|
("tezgah-rollback", os.path.join(HERE, "bin", "tezgah-rollback")),
|
|
1203
1408
|
("tezgah-agents", os.path.join(HERE, "bin", "tezgah-agents")),
|
|
1204
1409
|
("tezgah-context", os.path.join(HERE, "bin", "tezgah-context")),
|
|
1410
|
+
# cited by the contract skill and the taste hook as farm paths
|
|
1411
|
+
("tezgah-optimize", os.path.join(HERE, "bin", "tezgah-optimize")),
|
|
1412
|
+
("tezgah-taste", os.path.join(HERE, "bin", "tezgah-taste")),
|
|
1205
1413
|
("tezgah-capture", os.path.join(HERE, "bin", "tezgah-capture")),
|
|
1206
1414
|
("tezgah-doctor", os.path.join(HERE, "bin", "tezgah-doctor")),
|
|
1207
1415
|
("tezgah-setup", os.path.join(HERE, "bin", "tezgah-setup")),
|
|
@@ -1314,7 +1522,7 @@ def install_claude():
|
|
|
1314
1522
|
# tracked project file. Claude's hook drops the core when the block is there
|
|
1315
1523
|
# (`TEZGAH_CORE_IN_FILE`, hooks/projects-auto-init.py), so a session pays for
|
|
1316
1524
|
# the contract once.
|
|
1317
|
-
|
|
1525
|
+
write_rules("claude")
|
|
1318
1526
|
write_user_agents("claude")
|
|
1319
1527
|
register_claude_plugin()
|
|
1320
1528
|
|
|
@@ -1477,7 +1685,8 @@ def wire_claude_attribution():
|
|
|
1477
1685
|
|
|
1478
1686
|
def wire_claude_statusline():
|
|
1479
1687
|
"""Point Claude's statusLine at tezgah's wrapper (it forwards to Orca, then
|
|
1480
|
-
appends the tezgah segment).
|
|
1688
|
+
appends the tezgah segment). The user's own statusLine is recorded first, so
|
|
1689
|
+
uninstall puts it back. Backed up; skipped when already correct."""
|
|
1481
1690
|
sp = os.path.join(CLAUDE, "settings.json")
|
|
1482
1691
|
settings = read_json(sp, None)
|
|
1483
1692
|
if settings is None:
|
|
@@ -1487,6 +1696,8 @@ def wire_claude_statusline():
|
|
|
1487
1696
|
if (settings.get("statusLine") or {}).get("command") == cmd:
|
|
1488
1697
|
print("%s statusLine -> tezgah" % OK)
|
|
1489
1698
|
return
|
|
1699
|
+
if "statusline.py" not in json.dumps(settings.get("statusLine") or {}):
|
|
1700
|
+
remember_prior(sp, settings, "statusLine")
|
|
1490
1701
|
settings["statusLine"] = {"type": "command", "command": cmd}
|
|
1491
1702
|
write_json(sp, settings)
|
|
1492
1703
|
print("%s statusLine -> tezgah (previous kept as %s.<timestamp>.tezgah-bak)" % (OK, sp))
|
|
@@ -1533,7 +1744,7 @@ def install_codex():
|
|
|
1533
1744
|
# SessionStart hook's context is one message, where this file is the
|
|
1534
1745
|
# instructions Codex keeps for the whole session. Global, like omp's
|
|
1535
1746
|
# RULES.md, because a per-project file would be a tracked project file.
|
|
1536
|
-
|
|
1747
|
+
write_rules("codex")
|
|
1537
1748
|
write_user_agents("codex")
|
|
1538
1749
|
|
|
1539
1750
|
|
|
@@ -1658,9 +1869,11 @@ def strip_toml_mcp(path, names):
|
|
|
1658
1869
|
return False
|
|
1659
1870
|
out, skip, changed = [], False, False
|
|
1660
1871
|
for line in lines:
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1872
|
+
# every table header ends the one before it: a user table after a
|
|
1873
|
+
# tezgah [mcp_servers.*] one stays, and a tezgah sub-table goes with it
|
|
1874
|
+
if line.strip().startswith("["):
|
|
1875
|
+
m = re.match(r"\[mcp_servers\.([^\].]+)[\].]", line.strip())
|
|
1876
|
+
skip = bool(m) and m.group(1) in names
|
|
1664
1877
|
changed = changed or skip
|
|
1665
1878
|
if not skip:
|
|
1666
1879
|
out.append(line)
|
|
@@ -1719,8 +1932,12 @@ def write_artifact(path, text, write):
|
|
|
1719
1932
|
|
|
1720
1933
|
The writers are idempotent (write_if_changed, set_managed_md): a re-render
|
|
1721
1934
|
that produces the same bytes moves no mtime and lays no backup. Returns True
|
|
1722
|
-
when the file changed
|
|
1935
|
+
when the file changed, None when the writer refused it (named in REFUSED):
|
|
1936
|
+
a file never written gets no sha, so it still reads as stale."""
|
|
1937
|
+
refused = len(REFUSED)
|
|
1723
1938
|
changed = write(path, text)
|
|
1939
|
+
if len(REFUSED) > refused:
|
|
1940
|
+
return None
|
|
1724
1941
|
if recorded_shas().get(path) != _sha(text):
|
|
1725
1942
|
record_contract(path, text)
|
|
1726
1943
|
return changed
|
|
@@ -2055,7 +2272,10 @@ def install_opencode():
|
|
|
2055
2272
|
{"$schema": "https://opencode.ai/config.json"})
|
|
2056
2273
|
if cfg is None:
|
|
2057
2274
|
return
|
|
2275
|
+
# a file that already carries tezgah's wiring holds tezgah's prune, not the
|
|
2276
|
+
# user's: an install with no record must not store it as the prior value
|
|
2058
2277
|
instr = cfg.get("instructions") or []
|
|
2278
|
+
wired = any(isinstance(i, str) and "tezgah" in i for i in instr)
|
|
2059
2279
|
for extra in (contract, SKILL_ROUTER):
|
|
2060
2280
|
if extra not in instr:
|
|
2061
2281
|
instr.append(extra)
|
|
@@ -2103,6 +2323,9 @@ def install_opencode():
|
|
|
2103
2323
|
# "[Old tool result content cleared]" so a long session stops re-sending
|
|
2104
2324
|
# every output on every step. setdefault keeps a user's explicit choice.
|
|
2105
2325
|
if not v2: # opencode 2 omits it as an unsupported legacy setting
|
|
2326
|
+
comp = cfg.get("compaction")
|
|
2327
|
+
if not (wired and isinstance(comp, dict) and comp.get("prune") is True):
|
|
2328
|
+
remember_prior(os.path.join(OPENCODE, "opencode.json"), cfg, "compaction/prune")
|
|
2106
2329
|
cfg.setdefault("compaction", {}).setdefault("prune", True)
|
|
2107
2330
|
wat = cfg.setdefault("watcher", {})
|
|
2108
2331
|
ign = wat.setdefault("ignore", [])
|
|
@@ -2174,6 +2397,12 @@ def install_cursor():
|
|
|
2174
2397
|
cc = os.path.join(CURSOR, "cli-config.json")
|
|
2175
2398
|
cfg = read_config(cc, {})
|
|
2176
2399
|
if cfg is not None:
|
|
2400
|
+
# the user's own statusLine (and a file with no `version`) come back
|
|
2401
|
+
# at uninstall, from the install-prior record; a file already carrying
|
|
2402
|
+
# tezgah's statusLine holds tezgah's values, not the user's
|
|
2403
|
+
if "tezgah-statusline" not in json.dumps(cfg.get("statusLine") or {}):
|
|
2404
|
+
remember_prior(cc, cfg, "version")
|
|
2405
|
+
remember_prior(cc, cfg, "statusLine")
|
|
2177
2406
|
cfg.setdefault("version", 1)
|
|
2178
2407
|
cfg["statusLine"] = {
|
|
2179
2408
|
"type": "command",
|
|
@@ -2235,7 +2464,10 @@ def install_qwen():
|
|
|
2235
2464
|
arr = hooks.setdefault(ev, [])
|
|
2236
2465
|
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2237
2466
|
arr.append(group)
|
|
2238
|
-
# Qwen's command status line: the session JSON on stdin, ANSI kept
|
|
2467
|
+
# Qwen's command status line: the session JSON on stdin, ANSI kept;
|
|
2468
|
+
# the user's own comes back at uninstall, from the install-prior record
|
|
2469
|
+
if "tezgah-statusline" not in json.dumps(data.get("ui") or {}):
|
|
2470
|
+
remember_prior(path, data, "ui/statusLine")
|
|
2239
2471
|
data.setdefault("ui", {})["statusLine"] = {
|
|
2240
2472
|
"type": "command",
|
|
2241
2473
|
"command": tp.hook_command(
|
|
@@ -2246,7 +2478,7 @@ def install_qwen():
|
|
|
2246
2478
|
print("%s %s (hooks + ui.statusLine)" % (OK, path))
|
|
2247
2479
|
for s in SKILLS:
|
|
2248
2480
|
print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
|
|
2249
|
-
|
|
2481
|
+
write_rules("qwen")
|
|
2250
2482
|
|
|
2251
2483
|
|
|
2252
2484
|
def droid_hook_groups():
|
|
@@ -2275,7 +2507,7 @@ def read_jsonc(path):
|
|
|
2275
2507
|
text = fh.read()
|
|
2276
2508
|
except FileNotFoundError:
|
|
2277
2509
|
return {}
|
|
2278
|
-
except OSError:
|
|
2510
|
+
except (OSError, ValueError): # unreadable, or not UTF-8
|
|
2279
2511
|
return None
|
|
2280
2512
|
return jsonc_loads(text)
|
|
2281
2513
|
|
|
@@ -2387,7 +2619,7 @@ def install_droid():
|
|
|
2387
2619
|
text = fh.read()
|
|
2388
2620
|
except FileNotFoundError:
|
|
2389
2621
|
text = "{}\n"
|
|
2390
|
-
except OSError:
|
|
2622
|
+
except (OSError, ValueError): # unreadable, or not UTF-8
|
|
2391
2623
|
text = None
|
|
2392
2624
|
# the command's stdout is drawn above the input (settings reference)
|
|
2393
2625
|
line = {"command": droid_statusline()}
|
|
@@ -2400,6 +2632,10 @@ def install_droid():
|
|
|
2400
2632
|
elif isinstance(plain, dict):
|
|
2401
2633
|
# plain JSON (an earlier install wrote it): set both keys and rewrite, as before
|
|
2402
2634
|
ours = "tezgah-statusline" in json.dumps(plain.get("statusLine") or "")
|
|
2635
|
+
# the user's own trailer setting comes back at uninstall (OQ-15); a false
|
|
2636
|
+
# beside tezgah's own status line is the one an older install wrote
|
|
2637
|
+
if not (ours and plain.get("includeCoAuthoredByDroid") is False):
|
|
2638
|
+
remember_prior(spath, plain, "includeCoAuthoredByDroid")
|
|
2403
2639
|
if ours or "statusLine" not in plain:
|
|
2404
2640
|
plain["statusLine"] = line
|
|
2405
2641
|
plain["includeCoAuthoredByDroid"] = False
|
|
@@ -2407,7 +2643,12 @@ def install_droid():
|
|
|
2407
2643
|
print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
|
|
2408
2644
|
OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
|
|
2409
2645
|
elif text is not None:
|
|
2410
|
-
|
|
2646
|
+
found = jsonc_loads(text)
|
|
2647
|
+
old = (found or {}).get("statusLine")
|
|
2648
|
+
if isinstance(found, dict) and not (
|
|
2649
|
+
"tezgah-statusline" in json.dumps(old or "")
|
|
2650
|
+
and found.get("includeCoAuthoredByDroid") is False):
|
|
2651
|
+
remember_prior(spath, found, "includeCoAuthoredByDroid")
|
|
2411
2652
|
stale = old != line and "tezgah-statusline" in json.dumps(old or "")
|
|
2412
2653
|
if stale:
|
|
2413
2654
|
dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
|
|
@@ -2429,7 +2670,7 @@ def install_droid():
|
|
|
2429
2670
|
"`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
|
|
2430
2671
|
for s in SKILLS:
|
|
2431
2672
|
print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
|
|
2432
|
-
|
|
2673
|
+
write_rules("droid")
|
|
2433
2674
|
|
|
2434
2675
|
|
|
2435
2676
|
def commandcode_hook_groups():
|
|
@@ -2454,11 +2695,19 @@ def install_commandcode():
|
|
|
2454
2695
|
path = os.path.join(COMMANDCODE, "settings.json")
|
|
2455
2696
|
data = read_config(path, {})
|
|
2456
2697
|
if data is not None:
|
|
2698
|
+
attr = data.get("attribution")
|
|
2699
|
+
# hooks already naming tezgah: the "" commit beside them is the one an
|
|
2700
|
+
# older install wrote, not the user's prior value
|
|
2701
|
+
ours = "tezgah" in json.dumps(data.get("hooks") or {}) \
|
|
2702
|
+
and isinstance(attr, dict) and attr.get("commit") == ""
|
|
2457
2703
|
hooks = data.setdefault("hooks", {})
|
|
2458
2704
|
for ev, group in commandcode_hook_groups().items():
|
|
2459
2705
|
arr = hooks.setdefault(ev, [])
|
|
2460
2706
|
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2461
2707
|
arr.append(group)
|
|
2708
|
+
# a commit value the user already had comes back at uninstall (OQ-15)
|
|
2709
|
+
if not ours:
|
|
2710
|
+
remember_prior(path, data, "attribution/commit")
|
|
2462
2711
|
attr = data.setdefault("attribution", {})
|
|
2463
2712
|
if isinstance(attr, dict) and "commit" not in attr:
|
|
2464
2713
|
attr["commit"] = ""
|
|
@@ -2469,7 +2718,7 @@ def install_commandcode():
|
|
|
2469
2718
|
"co-author trailer" % (NO, path))
|
|
2470
2719
|
for s in SKILLS:
|
|
2471
2720
|
print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
|
|
2472
|
-
|
|
2721
|
+
write_rules("commandcode")
|
|
2473
2722
|
|
|
2474
2723
|
|
|
2475
2724
|
def copilot_hook_entries():
|
|
@@ -2505,9 +2754,16 @@ def install_copilot():
|
|
|
2505
2754
|
copilot_hook_entries().items()}})
|
|
2506
2755
|
print("%s %s (hooks)" % (OK, path))
|
|
2507
2756
|
spath = os.path.join(COPILOT, "settings.json")
|
|
2508
|
-
settings =
|
|
2757
|
+
settings = read_config(spath, {}, comments=True)
|
|
2509
2758
|
if isinstance(settings, dict):
|
|
2759
|
+
wired = "tezgah-statusline" in json.dumps(settings.get("statusLine") or {})
|
|
2760
|
+
if not wired:
|
|
2761
|
+
remember_prior(spath, settings, "statusLine")
|
|
2510
2762
|
settings["statusLine"] = {"type": "command", "command": copilot_statusline()}
|
|
2763
|
+
# a value the user already had comes back at uninstall (OQ-15); a false
|
|
2764
|
+
# beside tezgah's own status line is the one an older install wrote
|
|
2765
|
+
if not (wired and settings.get("includeCoAuthoredBy") is False):
|
|
2766
|
+
remember_prior(spath, settings, "includeCoAuthoredBy")
|
|
2511
2767
|
settings.setdefault("includeCoAuthoredBy", False)
|
|
2512
2768
|
write_json(spath, settings)
|
|
2513
2769
|
print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
|
|
@@ -2515,12 +2771,11 @@ def install_copilot():
|
|
|
2515
2771
|
print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
|
|
2516
2772
|
"co-author trailer" % (NO, spath))
|
|
2517
2773
|
else:
|
|
2518
|
-
print("%s
|
|
2519
|
-
"
|
|
2520
|
-
copilot_statusline()))
|
|
2774
|
+
print("%s set `statusLine` to `%s` and `includeCoAuthoredBy` to false in %s "
|
|
2775
|
+
"by hand" % (NO, copilot_statusline(), spath))
|
|
2521
2776
|
for s in SKILLS:
|
|
2522
2777
|
print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
|
|
2523
|
-
|
|
2778
|
+
write_rules("copilot")
|
|
2524
2779
|
|
|
2525
2780
|
|
|
2526
2781
|
def gemini_hook_groups():
|
|
@@ -2549,7 +2804,7 @@ def install_gemini():
|
|
|
2549
2804
|
print("%s %s (extension hooks)" % (OK, path))
|
|
2550
2805
|
for s in SKILLS:
|
|
2551
2806
|
print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
|
|
2552
|
-
|
|
2807
|
+
write_rules("gemini")
|
|
2553
2808
|
|
|
2554
2809
|
|
|
2555
2810
|
def antigravity_hook_groups():
|
|
@@ -2584,18 +2839,19 @@ def install_antigravity():
|
|
|
2584
2839
|
write_json(AGY_HOOKS, data)
|
|
2585
2840
|
print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
|
|
2586
2841
|
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
2587
|
-
settings =
|
|
2842
|
+
settings = read_config(spath, {}, comments=True)
|
|
2588
2843
|
if isinstance(settings, dict):
|
|
2844
|
+
if "tezgah-statusline" not in json.dumps(settings.get("statusLine") or {}):
|
|
2845
|
+
remember_prior(spath, settings, "statusLine")
|
|
2589
2846
|
settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
|
|
2590
2847
|
write_json(spath, settings)
|
|
2591
2848
|
print("%s %s (statusLine)" % (OK, spath))
|
|
2592
2849
|
else:
|
|
2593
|
-
print("%s
|
|
2594
|
-
"
|
|
2595
|
-
% (NO, spath, antigravity_statusline()))
|
|
2850
|
+
print("%s set `statusLine` to {\"type\": \"command\", \"command\": \"%s\"} in "
|
|
2851
|
+
"%s by hand" % (NO, antigravity_statusline(), spath))
|
|
2596
2852
|
for s in SKILLS:
|
|
2597
2853
|
print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
|
|
2598
|
-
|
|
2854
|
+
write_rules("antigravity")
|
|
2599
2855
|
|
|
2600
2856
|
|
|
2601
2857
|
def goose_hook_groups():
|
|
@@ -2626,18 +2882,28 @@ def install_goose():
|
|
|
2626
2882
|
print("%s %s (plugin hooks)" % (OK, path))
|
|
2627
2883
|
for s in SKILLS:
|
|
2628
2884
|
print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
|
|
2629
|
-
|
|
2885
|
+
write_rules("goose")
|
|
2630
2886
|
|
|
2631
2887
|
|
|
2632
|
-
def amp_settings():
|
|
2633
|
-
"""(path, settings dict or None): Amp's user settings
|
|
2634
|
-
rewrite it
|
|
2635
|
-
|
|
2636
|
-
|
|
2888
|
+
def amp_settings(rerun="--install"):
|
|
2889
|
+
"""(path, settings dict or None): Amp's user settings file as tezgah may
|
|
2890
|
+
rewrite it, the one answer for install, uninstall and the report.
|
|
2891
|
+
settings.jsonc stands in for settings.json when it is the only one; with
|
|
2892
|
+
both there, which Amp reads is undocumented and neither is touched. Absent
|
|
2893
|
+
reads as {}; a file holding comments is named and left untouched, an
|
|
2894
|
+
unreadable one refused. `rerun=None` (the report) reads without a word."""
|
|
2637
2895
|
path = os.path.join(AMP, "settings.json")
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2896
|
+
jsonc = os.path.join(AMP, "settings.jsonc")
|
|
2897
|
+
if os.path.exists(jsonc):
|
|
2898
|
+
if os.path.exists(path):
|
|
2899
|
+
if rerun:
|
|
2900
|
+
refuse(path, "%s exists beside it, and which of the two Amp reads "
|
|
2901
|
+
"is undocumented" % jsonc, rerun)
|
|
2902
|
+
return path, None
|
|
2903
|
+
path = jsonc
|
|
2904
|
+
if rerun is None:
|
|
2905
|
+
return path, read_jsonc(path)
|
|
2906
|
+
return path, read_config(path, {}, comments=True, rerun=rerun)
|
|
2641
2907
|
|
|
2642
2908
|
|
|
2643
2909
|
def install_amp():
|
|
@@ -2647,6 +2913,7 @@ def install_amp():
|
|
|
2647
2913
|
block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
|
|
2648
2914
|
in settings.json unless the user set them."""
|
|
2649
2915
|
print("\namp:")
|
|
2916
|
+
wired = amp_plugin_ours() # an earlier install's plugin, before this one writes
|
|
2650
2917
|
plugin = ta.amp_plugin(HERE)
|
|
2651
2918
|
if not plugin:
|
|
2652
2919
|
print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
|
|
@@ -2657,6 +2924,10 @@ def install_amp():
|
|
|
2657
2924
|
spath, settings = amp_settings()
|
|
2658
2925
|
if isinstance(settings, dict):
|
|
2659
2926
|
for key in AMP_ATTRIBUTION:
|
|
2927
|
+
# a value the user already had comes back at uninstall (OQ-15); a
|
|
2928
|
+
# false beside tezgah's own plugin is the one an older install wrote
|
|
2929
|
+
if not (wired and settings.get(key) is False):
|
|
2930
|
+
remember_prior(spath, settings, key)
|
|
2660
2931
|
settings.setdefault(key, False)
|
|
2661
2932
|
write_json(spath, settings)
|
|
2662
2933
|
print("%s %s (commit trailers off)" % (OK, spath))
|
|
@@ -2665,11 +2936,11 @@ def install_amp():
|
|
|
2665
2936
|
print("%s %s keeps your %s, so the agent's commits still carry "
|
|
2666
2937
|
"that trailer" % (NO, spath, key))
|
|
2667
2938
|
else:
|
|
2668
|
-
print("%s %s
|
|
2669
|
-
|
|
2939
|
+
print("%s set %s to false in %s by hand"
|
|
2940
|
+
% (NO, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION), spath))
|
|
2670
2941
|
for s in SKILLS:
|
|
2671
2942
|
print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
|
|
2672
|
-
|
|
2943
|
+
write_rules("amp")
|
|
2673
2944
|
|
|
2674
2945
|
|
|
2675
2946
|
|
|
@@ -2916,12 +3187,17 @@ def write_if_changed(path, text):
|
|
|
2916
3187
|
def set_managed_md(path, block, start="<!-- tezgah:start -->",
|
|
2917
3188
|
end="<!-- tezgah:end -->"):
|
|
2918
3189
|
"""Replace the marked block in a markdown file, or append it, keeping any
|
|
2919
|
-
user text around it. Markdown gets HTML-comment markers, not `#`.
|
|
3190
|
+
user text around it. Markdown gets HTML-comment markers, not `#`. A file
|
|
3191
|
+
that is there but cannot be read (no read bit, not UTF-8) is refused, never
|
|
3192
|
+
replaced by the block alone. Returns True when the file changed."""
|
|
2920
3193
|
try:
|
|
2921
3194
|
with open(path, encoding="utf-8") as fh:
|
|
2922
3195
|
lines = fh.read().splitlines()
|
|
2923
|
-
except
|
|
3196
|
+
except FileNotFoundError:
|
|
2924
3197
|
lines = []
|
|
3198
|
+
except (OSError, ValueError) as exc:
|
|
3199
|
+
refuse(path, read_failure(exc))
|
|
3200
|
+
return False
|
|
2925
3201
|
out, skip = [], False
|
|
2926
3202
|
for ln in lines:
|
|
2927
3203
|
if ln.strip().startswith(start):
|
|
@@ -3018,7 +3294,10 @@ def strip_managed_md(path):
|
|
|
3018
3294
|
try:
|
|
3019
3295
|
with open(path, encoding="utf-8") as fh:
|
|
3020
3296
|
text = fh.read()
|
|
3021
|
-
except
|
|
3297
|
+
except FileNotFoundError:
|
|
3298
|
+
return
|
|
3299
|
+
except (OSError, ValueError) as exc:
|
|
3300
|
+
refuse(path, read_failure(exc), "--uninstall")
|
|
3022
3301
|
return
|
|
3023
3302
|
kept = re.sub(r"<!-- tezgah:start -->.*?<!-- tezgah:end -->\n?",
|
|
3024
3303
|
"", text, flags=re.S).strip()
|
|
@@ -3092,10 +3371,11 @@ def rules_block(host):
|
|
|
3092
3371
|
|
|
3093
3372
|
|
|
3094
3373
|
def write_rules(host):
|
|
3095
|
-
"""Render the host's block into its rules file
|
|
3374
|
+
"""Render the host's block into its rules file, record its sha and print
|
|
3375
|
+
its line; a refused file is named once, by the refusal, with no `ok`."""
|
|
3096
3376
|
path = STATIC_RULES[host]()
|
|
3097
|
-
write_artifact(path, rules_block(host), set_managed_md)
|
|
3098
|
-
|
|
3377
|
+
if write_artifact(path, rules_block(host), set_managed_md) is not None:
|
|
3378
|
+
print("%s %s (always-on contract)" % (OK, path))
|
|
3099
3379
|
|
|
3100
3380
|
|
|
3101
3381
|
def rules_current(host):
|
|
@@ -3107,7 +3387,7 @@ def rules_current(host):
|
|
|
3107
3387
|
try:
|
|
3108
3388
|
with open(STATIC_RULES[host](), encoding="utf-8") as fh:
|
|
3109
3389
|
lines = fh.read().splitlines()
|
|
3110
|
-
except OSError:
|
|
3390
|
+
except (OSError, ValueError): # unreadable, or not UTF-8: not current
|
|
3111
3391
|
return False
|
|
3112
3392
|
starts = [i for i, ln in enumerate(lines)
|
|
3113
3393
|
if ln.strip().startswith("<!-- tezgah:start -->")]
|
|
@@ -3128,7 +3408,7 @@ def omp_bridge():
|
|
|
3128
3408
|
def install_omp():
|
|
3129
3409
|
print("\nomp:")
|
|
3130
3410
|
mkdirp(OMP_AGENT)
|
|
3131
|
-
|
|
3411
|
+
write_rules("omp")
|
|
3132
3412
|
for s in SKILLS:
|
|
3133
3413
|
print(link(os.path.join(HERE, "skills", s),
|
|
3134
3414
|
os.path.join(OMP_AGENT, "skills", s)))
|
|
@@ -3656,9 +3936,11 @@ def is_tezgah_link(path):
|
|
|
3656
3936
|
wiring the user's."""
|
|
3657
3937
|
if not os.path.islink(path):
|
|
3658
3938
|
return False
|
|
3939
|
+
# every root resolved like the target: a HOME reached through a symlink
|
|
3940
|
+
# (macOS /tmp, Fedora's /home) otherwise reads tezgah's own hop as a stranger
|
|
3659
3941
|
target = os.path.realpath(path)
|
|
3660
3942
|
return any(target == root or target.startswith(root + os.sep)
|
|
3661
|
-
for root in (os.path.realpath(HERE
|
|
3943
|
+
for root in map(os.path.realpath, (HERE, INSTALL_PREFIX, tp.BIN_DIR)))
|
|
3662
3944
|
|
|
3663
3945
|
|
|
3664
3946
|
def is_tezgah_entry(path):
|
|
@@ -3698,6 +3980,9 @@ def unlink_dir(d):
|
|
|
3698
3980
|
def drop_managed_hook_entries(path):
|
|
3699
3981
|
"""Remove only tezgah's own entries from a host hooks.json."""
|
|
3700
3982
|
data = read_json(path, None)
|
|
3983
|
+
if data is not None and not isinstance(data, dict):
|
|
3984
|
+
print("%s %s left untouched (not a JSON object)" % (NO, path))
|
|
3985
|
+
return
|
|
3701
3986
|
if not data:
|
|
3702
3987
|
print("%s no %s" % (OK, path))
|
|
3703
3988
|
return
|
|
@@ -3724,9 +4009,12 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3724
4009
|
try:
|
|
3725
4010
|
with open(path, encoding="utf-8") as fh:
|
|
3726
4011
|
lines = fh.read().splitlines()
|
|
3727
|
-
except
|
|
4012
|
+
except FileNotFoundError:
|
|
3728
4013
|
print("%s no %s" % (OK, path))
|
|
3729
4014
|
return
|
|
4015
|
+
except (OSError, ValueError) as exc:
|
|
4016
|
+
refuse(path, read_failure(exc), "--uninstall")
|
|
4017
|
+
return
|
|
3730
4018
|
out, skip, changed = [], False, False
|
|
3731
4019
|
for ln in lines:
|
|
3732
4020
|
stripped = ln.strip()
|
|
@@ -3749,8 +4037,17 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3749
4037
|
return
|
|
3750
4038
|
while out and not out[-1].strip():
|
|
3751
4039
|
out.pop()
|
|
3752
|
-
# A dsh patch layer
|
|
3753
|
-
#
|
|
4040
|
+
# A dsh patch layer the install created goes whole once nothing but
|
|
4041
|
+
# tezgah's block was in it (OQ-13); one the user had must stay a top-level
|
|
4042
|
+
# YAML array: a file left holding only comments parses to null and the
|
|
4043
|
+
# next dsh boot fails on it.
|
|
4044
|
+
if not out and prior_created(path):
|
|
4045
|
+
backup(path)
|
|
4046
|
+
os.remove(path)
|
|
4047
|
+
forget_prior(path)
|
|
4048
|
+
print("%s removed %s (it held only tezgah's block)" % (OK, path))
|
|
4049
|
+
return
|
|
4050
|
+
forget_prior(path)
|
|
3754
4051
|
if not any(ln.strip() and not ln.strip().startswith("#") for ln in out):
|
|
3755
4052
|
out.append("[]")
|
|
3756
4053
|
with open(path, "w", encoding="utf-8") as fh:
|
|
@@ -3759,8 +4056,6 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3759
4056
|
|
|
3760
4057
|
|
|
3761
4058
|
def uninstall_common():
|
|
3762
|
-
print("\ncommon:")
|
|
3763
|
-
unlink_dir(tp.BIN_DIR)
|
|
3764
4059
|
removed = tezgah_agents.cleanup()
|
|
3765
4060
|
if removed:
|
|
3766
4061
|
print("%s removed %d generated repo agent file(s)" % (OK, removed))
|
|
@@ -3870,20 +4165,21 @@ def _drop_state_entry(path):
|
|
|
3870
4165
|
|
|
3871
4166
|
|
|
3872
4167
|
def remove_generated_state(full):
|
|
3873
|
-
"""Take tezgah's state back
|
|
3874
|
-
|
|
4168
|
+
"""Take tezgah's state back when the uninstall is full: the cache dirs and
|
|
4169
|
+
the config dir's generated contents.
|
|
3875
4170
|
|
|
3876
4171
|
`full` means the run covers every host config.json records - the armed set.
|
|
3877
|
-
A partial uninstall keeps
|
|
3878
|
-
|
|
4172
|
+
A partial uninstall keeps both: the hosts still armed read their roots and
|
|
4173
|
+
feature selection from the config and write their session state and app
|
|
4174
|
+
artifacts into the cache at runtime, and deleting either would degrade
|
|
3879
4175
|
wiring this run did not touch. `adopted/` and any entry that is not one of
|
|
3880
4176
|
tezgah's generated names survive in both cases."""
|
|
4177
|
+
if not full:
|
|
4178
|
+
return
|
|
3881
4179
|
for d in (tp.CACHE, tp.fallback_cache()):
|
|
3882
4180
|
if os.path.isdir(d):
|
|
3883
4181
|
shutil.rmtree(d, ignore_errors=True)
|
|
3884
4182
|
print("%s removed %s" % (OK, d))
|
|
3885
|
-
if not full:
|
|
3886
|
-
return
|
|
3887
4183
|
try:
|
|
3888
4184
|
names = sorted(os.listdir(tp.CONFIG_DIR))
|
|
3889
4185
|
except OSError as exc:
|
|
@@ -3969,14 +4265,22 @@ def remove_install_tree():
|
|
|
3969
4265
|
print("%s kept %s (holds %s)" % (NO, INSTALL_PREFIX, ", ".join(sorted(rest))))
|
|
3970
4266
|
|
|
3971
4267
|
|
|
3972
|
-
def
|
|
3973
|
-
"""
|
|
4268
|
+
def text_marks(path):
|
|
4269
|
+
"""`path` as a host file can hold it: raw (TOML, markdown, a POSIX JSON
|
|
4270
|
+
string) and JSON-escaped, where a Windows path's backslashes are doubled."""
|
|
4271
|
+
return tuple(sorted({path, json.dumps(path)[1:-1]}))
|
|
4272
|
+
|
|
4273
|
+
|
|
4274
|
+
def _text_clean(path, needle):
|
|
4275
|
+
"""(True, "") when `path` is gone or carries none of `needle` (one marker,
|
|
4276
|
+
or a tuple of them); else the leftover."""
|
|
3974
4277
|
try:
|
|
3975
4278
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
3976
4279
|
body = fh.read()
|
|
3977
4280
|
except OSError:
|
|
3978
4281
|
return True, ""
|
|
3979
|
-
|
|
4282
|
+
needles = (needle,) if isinstance(needle, str) else needle
|
|
4283
|
+
return not any(n in body for n in needles), path
|
|
3980
4284
|
|
|
3981
4285
|
|
|
3982
4286
|
def verify_uninstall(hosts, full):
|
|
@@ -3996,7 +4300,21 @@ def verify_uninstall(hosts, full):
|
|
|
3996
4300
|
else:
|
|
3997
4301
|
print("%s %s" % (OK, label))
|
|
3998
4302
|
|
|
3999
|
-
|
|
4303
|
+
# the markers only tezgah writes, so a user's own text naming tezgah (a
|
|
4304
|
+
# Codex `[projects."…/tezgah"]` table, a note) is never a leftover: every
|
|
4305
|
+
# hook and status-line command runs from the farm under CONFIG_DIR, and an
|
|
4306
|
+
# MCP row is one of tezgah's server names (B-123)
|
|
4307
|
+
farm_mark = text_marks(tp.CONFIG_DIR + os.sep)
|
|
4308
|
+
names = [TEZGAH_MCP_NAME] + app_mcp_names()
|
|
4309
|
+
json_mcp = tuple('"%s":' % n for n in names)
|
|
4310
|
+
toml_mcp = tuple("[mcp_servers.%s]" % n for n in names)
|
|
4311
|
+
|
|
4312
|
+
def skills(root):
|
|
4313
|
+
"""tezgah's own skill links under root; a user's same-named dir is theirs."""
|
|
4314
|
+
return [p for p in (os.path.join(root, "skills", s) for s in SKILLS)
|
|
4315
|
+
if is_tezgah_entry(p)]
|
|
4316
|
+
|
|
4317
|
+
def clean(label, paths, needle=farm_mark):
|
|
4000
4318
|
left = []
|
|
4001
4319
|
for p in paths:
|
|
4002
4320
|
ok, leftover = _text_clean(p, needle)
|
|
@@ -4008,13 +4326,17 @@ def verify_uninstall(hosts, full):
|
|
|
4008
4326
|
else:
|
|
4009
4327
|
print("%s %s" % (OK, label))
|
|
4010
4328
|
|
|
4011
|
-
# common state
|
|
4329
|
+
# common state: a partial run keeps the farm the armed hosts still run
|
|
4330
|
+
# from, and takes only the hook links of the hosts it removed
|
|
4012
4331
|
farm = [os.path.join(tp.BIN_DIR, n)
|
|
4013
4332
|
for n in (os.listdir(tp.BIN_DIR) if os.path.isdir(tp.BIN_DIR) else [])]
|
|
4014
|
-
|
|
4015
|
-
|
|
4333
|
+
gone = {"tezgah-%s-hook" % h for h in hosts}
|
|
4334
|
+
claim("config/bin farm holds no tezgah entry" if full
|
|
4335
|
+
else "config/bin farm holds no hook of these hosts",
|
|
4336
|
+
[p for p in farm if is_tezgah_entry(p)
|
|
4337
|
+
and (full or os.path.basename(p) in gone)])
|
|
4016
4338
|
claim("materialised ledger gone", [MATERIALISED] if full else [])
|
|
4017
|
-
claim("state caches gone", [tp.CACHE, tp.fallback_cache()])
|
|
4339
|
+
claim("state caches gone", [tp.CACHE, tp.fallback_cache()] if full else [])
|
|
4018
4340
|
if full:
|
|
4019
4341
|
try:
|
|
4020
4342
|
has_tree = os.path.isdir(INSTALL_PREFIX) and any(
|
|
@@ -4038,11 +4360,11 @@ def verify_uninstall(hosts, full):
|
|
|
4038
4360
|
for n in user_agent_files("claude")])
|
|
4039
4361
|
if "codex" in hosts:
|
|
4040
4362
|
clean("codex hooks.json unwired", [os.path.join(CODEX, "hooks.json")])
|
|
4041
|
-
claim("codex skills unlinked",
|
|
4042
|
-
|
|
4043
|
-
|
|
4044
|
-
|
|
4045
|
-
claim("codex consult link gone", [
|
|
4363
|
+
claim("codex skills unlinked", skills(CODEX))
|
|
4364
|
+
clean("codex config.toml unwired", [os.path.join(CODEX, "config.toml")],
|
|
4365
|
+
needle=toml_mcp)
|
|
4366
|
+
consult = os.path.join(CODEX, "bin", "consult")
|
|
4367
|
+
claim("codex consult link gone", [consult] if is_tezgah_entry(consult) else [])
|
|
4046
4368
|
# the user's own AGENTS.md may name tezgah; only the block is ours
|
|
4047
4369
|
clean("codex global AGENTS.md block gone",
|
|
4048
4370
|
[os.path.join(CODEX, n) for n in CODEX_RULES],
|
|
@@ -4056,20 +4378,24 @@ def verify_uninstall(hosts, full):
|
|
|
4056
4378
|
+ [os.path.join(OPENCODE, "tui-plugins", n)
|
|
4057
4379
|
for n in ("tezgah-tui.tsx", "tezgah-cli")])
|
|
4058
4380
|
clean("opencode opencode.json unwired",
|
|
4059
|
-
[os.path.join(OPENCODE, "opencode.json")]
|
|
4381
|
+
[os.path.join(OPENCODE, "opencode.json")],
|
|
4382
|
+
needle=farm_mark + text_marks(os.path.join(HERE, "skills", "**")) + json_mcp)
|
|
4060
4383
|
clean("opencode cli.json unwired", [os.path.join(OPENCODE, "cli.json")],
|
|
4061
4384
|
needle=OPENCODE_CLI_PLUGIN)
|
|
4062
4385
|
claim("opencode generated routers gone", [SKILL_ROUTER, SKILL_ROUTER_FULL])
|
|
4063
4386
|
if "cursor" in hosts:
|
|
4064
4387
|
clean("cursor hooks.json unwired", [os.path.join(CURSOR, "hooks.json")])
|
|
4065
|
-
clean("cursor mcp.json unwired", [os.path.join(CURSOR, "mcp.json")]
|
|
4388
|
+
clean("cursor mcp.json unwired", [os.path.join(CURSOR, "mcp.json")],
|
|
4389
|
+
needle=json_mcp)
|
|
4066
4390
|
clean("cursor cli-config.json unwired",
|
|
4067
4391
|
[os.path.join(CURSOR, "cli-config.json")])
|
|
4068
4392
|
if "dsh" in hosts:
|
|
4069
|
-
clean("dsh patch layers unwired", [DSH_PATCH, DSH_WEB_PATCH]
|
|
4393
|
+
clean("dsh patch layers unwired", [DSH_PATCH, DSH_WEB_PATCH],
|
|
4394
|
+
needle="# tezgah:start")
|
|
4070
4395
|
claim("dsh launcher gone", [dsh_launcher()[1]])
|
|
4071
4396
|
if "omp" in hosts:
|
|
4072
|
-
clean("omp RULES.md unwired", [os.path.join(OMP_AGENT, "RULES.md")]
|
|
4397
|
+
clean("omp RULES.md unwired", [os.path.join(OMP_AGENT, "RULES.md")],
|
|
4398
|
+
needle="<!-- tezgah:start")
|
|
4073
4399
|
claim("omp hook script gone",
|
|
4074
4400
|
[os.path.join(OMP_AGENT, "hooks", "pre", "tezgah-hook.ts")])
|
|
4075
4401
|
agents = os.path.join(OMP_AGENT, "agents")
|
|
@@ -4077,34 +4403,27 @@ def verify_uninstall(hosts, full):
|
|
|
4077
4403
|
[os.path.join(agents, n) for n in
|
|
4078
4404
|
(os.listdir(agents) if os.path.isdir(agents) else [])
|
|
4079
4405
|
if n[:-len(".md")] in tezgah_agents.OWNED_NAMES and n.endswith(".md")])
|
|
4080
|
-
clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")]
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
if os.path.lexists(os.path.join(OMP_AGENT, "skills", s))])
|
|
4406
|
+
clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")],
|
|
4407
|
+
needle=json_mcp)
|
|
4408
|
+
claim("omp skills unlinked", skills(OMP_AGENT))
|
|
4084
4409
|
if "qwen" in hosts:
|
|
4085
4410
|
clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
|
|
4086
4411
|
clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
|
|
4087
4412
|
needle="<!-- tezgah:start")
|
|
4088
|
-
claim("qwen skills unlinked",
|
|
4089
|
-
[os.path.join(QWEN, "skills", s) for s in SKILLS
|
|
4090
|
-
if os.path.lexists(os.path.join(QWEN, "skills", s))])
|
|
4413
|
+
claim("qwen skills unlinked", skills(QWEN))
|
|
4091
4414
|
if "droid" in hosts:
|
|
4092
4415
|
clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
|
|
4093
4416
|
clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
|
|
4094
4417
|
needle="tezgah-statusline")
|
|
4095
4418
|
clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
|
|
4096
4419
|
needle="<!-- tezgah:start")
|
|
4097
|
-
claim("droid skills unlinked",
|
|
4098
|
-
[os.path.join(DROID, "skills", s) for s in SKILLS
|
|
4099
|
-
if os.path.lexists(os.path.join(DROID, "skills", s))])
|
|
4420
|
+
claim("droid skills unlinked", skills(DROID))
|
|
4100
4421
|
if "commandcode" in hosts:
|
|
4101
4422
|
clean("commandcode settings.json unwired",
|
|
4102
4423
|
[os.path.join(COMMANDCODE, "settings.json")])
|
|
4103
4424
|
clean("commandcode global AGENTS.md block gone",
|
|
4104
4425
|
[os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
|
|
4105
|
-
claim("commandcode skills unlinked",
|
|
4106
|
-
[os.path.join(COMMANDCODE, "skills", s) for s in SKILLS
|
|
4107
|
-
if os.path.lexists(os.path.join(COMMANDCODE, "skills", s))])
|
|
4426
|
+
claim("commandcode skills unlinked", skills(COMMANDCODE))
|
|
4108
4427
|
if "copilot" in hosts:
|
|
4109
4428
|
claim("copilot hooks/tezgah.json gone",
|
|
4110
4429
|
[os.path.join(COPILOT, "hooks", "tezgah.json")])
|
|
@@ -4112,9 +4431,7 @@ def verify_uninstall(hosts, full):
|
|
|
4112
4431
|
[os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
|
|
4113
4432
|
clean("copilot copilot-instructions.md block gone",
|
|
4114
4433
|
[os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
|
|
4115
|
-
claim("copilot skills unlinked",
|
|
4116
|
-
[os.path.join(COPILOT, "skills", s) for s in SKILLS
|
|
4117
|
-
if os.path.lexists(os.path.join(COPILOT, "skills", s))])
|
|
4434
|
+
claim("copilot skills unlinked", skills(COPILOT))
|
|
4118
4435
|
if "gemini" in hosts:
|
|
4119
4436
|
claim("gemini extension dir gone", [GEMINI_EXT])
|
|
4120
4437
|
if not gemini_md_kept("gemini"):
|
|
@@ -4125,9 +4442,7 @@ def verify_uninstall(hosts, full):
|
|
|
4125
4442
|
needle="tezgah-antigravity-hook")
|
|
4126
4443
|
clean("antigravity settings.json statusLine gone",
|
|
4127
4444
|
[os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
|
|
4128
|
-
claim("antigravity skills unlinked",
|
|
4129
|
-
[os.path.join(ANTIGRAVITY, "skills", s) for s in SKILLS
|
|
4130
|
-
if os.path.lexists(os.path.join(ANTIGRAVITY, "skills", s))])
|
|
4445
|
+
claim("antigravity skills unlinked", skills(ANTIGRAVITY))
|
|
4131
4446
|
if not gemini_md_kept("antigravity"):
|
|
4132
4447
|
clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
|
|
4133
4448
|
needle="<!-- tezgah:start")
|
|
@@ -4137,14 +4452,12 @@ def verify_uninstall(hosts, full):
|
|
|
4137
4452
|
needle="<!-- tezgah:start")
|
|
4138
4453
|
if "amp" in hosts:
|
|
4139
4454
|
claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
|
|
4140
|
-
claim("amp skills unlinked",
|
|
4141
|
-
[os.path.join(AMP, "skills", s) for s in SKILLS
|
|
4142
|
-
if os.path.lexists(os.path.join(AMP, "skills", s))])
|
|
4455
|
+
claim("amp skills unlinked", skills(AMP))
|
|
4143
4456
|
clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
|
|
4144
4457
|
needle="<!-- tezgah:start")
|
|
4145
4458
|
if "claude" in hosts:
|
|
4146
4459
|
clean("claude settings.json unwired",
|
|
4147
|
-
[os.path.join(CLAUDE, "settings.json")])
|
|
4460
|
+
[os.path.join(CLAUDE, "settings.json")], needle=farm_mark + ('"tezgah@',))
|
|
4148
4461
|
claim("legacy kill switches gone",
|
|
4149
4462
|
[os.path.join(HOME, ".claude", n) for n in OFF_SWITCH_NAMES
|
|
4150
4463
|
if os.path.exists(os.path.join(HOME, ".claude", n))])
|
|
@@ -4158,16 +4471,16 @@ def verify_uninstall(hosts, full):
|
|
|
4158
4471
|
|
|
4159
4472
|
|
|
4160
4473
|
def unwire_claude_settings():
|
|
4161
|
-
"""
|
|
4162
|
-
|
|
4474
|
+
"""Take back tezgah's statusLine (the user's own comes back from the
|
|
4475
|
+
install-prior record), attribution and enabledPlugins rows, leaving
|
|
4476
|
+
everything else; an `enabledPlugins` left empty goes too."""
|
|
4163
4477
|
sp = os.path.join(CLAUDE, "settings.json")
|
|
4164
4478
|
s = read_json(sp, None)
|
|
4165
|
-
if not s:
|
|
4479
|
+
if not s or not isinstance(s, dict):
|
|
4166
4480
|
return
|
|
4167
4481
|
changed = False
|
|
4168
4482
|
if "statusline.py" in json.dumps(s.get("statusLine") or {}):
|
|
4169
|
-
s
|
|
4170
|
-
changed = True
|
|
4483
|
+
changed = restore_prior(sp, s, "statusLine")
|
|
4171
4484
|
if s.get("attribution") == {"commit": "", "pr": "", "sessionUrl": False}:
|
|
4172
4485
|
s.pop("attribution", None)
|
|
4173
4486
|
changed = True
|
|
@@ -4176,9 +4489,12 @@ def unwire_claude_settings():
|
|
|
4176
4489
|
if isinstance(enabled, dict) else []:
|
|
4177
4490
|
del enabled[key]
|
|
4178
4491
|
changed = True
|
|
4492
|
+
if not enabled:
|
|
4493
|
+
s.pop("enabledPlugins")
|
|
4179
4494
|
if changed:
|
|
4180
4495
|
write_json(sp, s)
|
|
4181
4496
|
print("%s removed tezgah statusLine/attribution/enabledPlugins from %s (restore from its .tezgah-bak copies under %s if wanted)" % (OK, sp, BACKUP_DIR))
|
|
4497
|
+
forget_prior(sp)
|
|
4182
4498
|
|
|
4183
4499
|
|
|
4184
4500
|
def unwire_opencode_config():
|
|
@@ -4217,8 +4533,9 @@ def unwire_opencode_config():
|
|
|
4217
4533
|
changed = True
|
|
4218
4534
|
comp = c.get("compaction")
|
|
4219
4535
|
if isinstance(comp, dict) and comp.get("prune") is True:
|
|
4220
|
-
|
|
4221
|
-
|
|
4536
|
+
# only the `prune` install added, and `compaction` only when the
|
|
4537
|
+
# install created it and nothing else is left in it
|
|
4538
|
+
changed = restore_prior(p, c, "compaction/prune") or changed
|
|
4222
4539
|
wat = c.get("watcher")
|
|
4223
4540
|
if isinstance(wat, dict) and isinstance(wat.get("ignore"), list):
|
|
4224
4541
|
kept = [p for p in wat["ignore"] if p not in OPENCODE_IGNORE]
|
|
@@ -4241,6 +4558,7 @@ def unwire_opencode_config():
|
|
|
4241
4558
|
if changed:
|
|
4242
4559
|
write_json(p, c)
|
|
4243
4560
|
print("%s removed tezgah instructions/skill-deny from %s" % (OK, p))
|
|
4561
|
+
forget_prior(p)
|
|
4244
4562
|
t = os.path.join(OPENCODE, "tui.json")
|
|
4245
4563
|
tj = read_json(t, None)
|
|
4246
4564
|
if tj and tj.get("plugin"):
|
|
@@ -4253,6 +4571,7 @@ def unwire_opencode_config():
|
|
|
4253
4571
|
tj.pop("plugin", None)
|
|
4254
4572
|
write_json(t, tj)
|
|
4255
4573
|
print("%s removed tezgah TUI plugin from %s" % (OK, t))
|
|
4574
|
+
drop_if_empty(t, {"$schema": "https://opencode.ai/tui.json"})
|
|
4256
4575
|
cp = os.path.join(OPENCODE, "cli.json")
|
|
4257
4576
|
cj = read_json(cp, None)
|
|
4258
4577
|
if cj and isinstance(cj.get("plugins"), list):
|
|
@@ -4261,6 +4580,7 @@ def unwire_opencode_config():
|
|
|
4261
4580
|
cj["plugins"] = kept
|
|
4262
4581
|
write_json(cp, cj)
|
|
4263
4582
|
print("%s removed tezgah CLI plugin from %s" % (OK, cp))
|
|
4583
|
+
forget_prior(cp)
|
|
4264
4584
|
|
|
4265
4585
|
|
|
4266
4586
|
def uninstall_claude():
|
|
@@ -4307,6 +4627,7 @@ def uninstall_codex():
|
|
|
4307
4627
|
for s in SKILLS:
|
|
4308
4628
|
unlink_tezgah(os.path.join(CODEX, "skills", s))
|
|
4309
4629
|
drop_managed_hook_entries(os.path.join(CODEX, "hooks.json"))
|
|
4630
|
+
drop_if_empty(os.path.join(CODEX, "hooks.json"))
|
|
4310
4631
|
strip_toml_mcp(os.path.join(CODEX, "config.toml"),
|
|
4311
4632
|
app_mcp_names() + [TEZGAH_MCP_NAME])
|
|
4312
4633
|
unlink_tezgah(os.path.join(CODEX, "bin", "consult"))
|
|
@@ -4338,6 +4659,7 @@ def uninstall_cursor():
|
|
|
4338
4659
|
for s in SKILLS:
|
|
4339
4660
|
unlink_tezgah(os.path.join(CURSOR, "skills", s))
|
|
4340
4661
|
drop_managed_hook_entries(os.path.join(CURSOR, "hooks.json"))
|
|
4662
|
+
drop_if_empty(os.path.join(CURSOR, "hooks.json"), {"version": 1})
|
|
4341
4663
|
mpath = os.path.join(CURSOR, "mcp.json")
|
|
4342
4664
|
mcfg = read_json(mpath, None)
|
|
4343
4665
|
if mcfg and isinstance(mcfg.get("mcpServers"), dict):
|
|
@@ -4352,9 +4674,13 @@ def uninstall_cursor():
|
|
|
4352
4674
|
cfg = read_json(cc, None)
|
|
4353
4675
|
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(
|
|
4354
4676
|
cfg.get("statusLine") or {}):
|
|
4355
|
-
|
|
4677
|
+
# the user's own statusLine comes back, and `version` goes when the
|
|
4678
|
+
# install added it to a file that had none
|
|
4679
|
+
restore_prior(cc, cfg, "statusLine")
|
|
4680
|
+
restore_prior(cc, cfg, "version", keep_unrecorded=True)
|
|
4356
4681
|
write_json(cc, cfg)
|
|
4357
4682
|
print("%s removed tezgah statusLine from %s" % (OK, cc))
|
|
4683
|
+
drop_if_empty(cc, {"version": 1})
|
|
4358
4684
|
|
|
4359
4685
|
|
|
4360
4686
|
def uninstall_qwen():
|
|
@@ -4366,22 +4692,35 @@ def uninstall_qwen():
|
|
|
4366
4692
|
cfg = read_json(path, None)
|
|
4367
4693
|
ui = cfg.get("ui") if isinstance(cfg, dict) else None
|
|
4368
4694
|
if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
|
|
4369
|
-
|
|
4370
|
-
if not ui:
|
|
4371
|
-
cfg.pop("ui")
|
|
4695
|
+
restore_prior(path, cfg, "ui/statusLine")
|
|
4372
4696
|
write_json(path, cfg)
|
|
4373
4697
|
print("%s removed tezgah statusLine from %s" % (OK, path))
|
|
4374
4698
|
drop_if_empty(path)
|
|
4375
4699
|
strip_managed_md(os.path.join(QWEN, "QWEN.md"))
|
|
4376
4700
|
|
|
4377
4701
|
|
|
4378
|
-
def drop_if_empty(path):
|
|
4379
|
-
"""Remove a host
|
|
4380
|
-
|
|
4381
|
-
|
|
4702
|
+
def drop_if_empty(path, shell=None):
|
|
4703
|
+
"""Remove a host file tezgah created (the install-prior record says so)
|
|
4704
|
+
once uninstall emptied it: `{}`, or the bare `shell` its installer seeds a
|
|
4705
|
+
new file with. A file the user had before the install stays, `{}` or not;
|
|
4706
|
+
one an install older than the record wrote goes when emptied, as it always
|
|
4707
|
+
did. The record of the file goes once its uninstall is done - not while
|
|
4708
|
+
the file cannot be read: the refusal asks for a re-run, which still needs
|
|
4709
|
+
the user's prior values."""
|
|
4710
|
+
try:
|
|
4711
|
+
with open(path, encoding="utf-8") as fh:
|
|
4712
|
+
fh.read()
|
|
4713
|
+
except FileNotFoundError:
|
|
4714
|
+
pass
|
|
4715
|
+
except (OSError, ValueError): # unreadable, or not UTF-8
|
|
4716
|
+
return
|
|
4717
|
+
data = read_json(path, None)
|
|
4718
|
+
if (data == {} or (shell is not None and data == shell)) \
|
|
4719
|
+
and prior_created(path) is not False:
|
|
4382
4720
|
backup(path)
|
|
4383
4721
|
os.remove(path)
|
|
4384
4722
|
print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
|
|
4723
|
+
forget_prior(path)
|
|
4385
4724
|
|
|
4386
4725
|
|
|
4387
4726
|
def uninstall_droid():
|
|
@@ -4410,18 +4749,38 @@ def uninstall_droid():
|
|
|
4410
4749
|
print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
|
|
4411
4750
|
spath = os.path.join(DROID, "settings.json")
|
|
4412
4751
|
cfg = read_json(spath, None)
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4752
|
+
key = "includeCoAuthoredByDroid"
|
|
4753
|
+
if isinstance(cfg, dict):
|
|
4754
|
+
changed = False
|
|
4755
|
+
if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4756
|
+
cfg.pop("statusLine")
|
|
4757
|
+
changed = True
|
|
4758
|
+
# the trailer switch goes back to what the install found (OQ-15); an
|
|
4759
|
+
# install older than the record leaves it false, as it always did
|
|
4760
|
+
if cfg.get(key) is False:
|
|
4761
|
+
changed = restore_prior(spath, cfg, key, keep_unrecorded=True) or changed
|
|
4762
|
+
if changed:
|
|
4763
|
+
write_json(spath, cfg)
|
|
4764
|
+
print("%s removed tezgah statusLine from %s" % (OK, spath))
|
|
4417
4765
|
elif cfg is None and os.path.exists(spath):
|
|
4418
|
-
# JSON with comments: take out the
|
|
4419
|
-
# the comments
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4766
|
+
# JSON with comments: take out the lines install_droid put in, keep
|
|
4767
|
+
# the comments
|
|
4768
|
+
try:
|
|
4769
|
+
with open(spath, encoding="utf-8") as fh:
|
|
4770
|
+
text = fh.read()
|
|
4771
|
+
except (OSError, ValueError) as exc: # unreadable, or not UTF-8
|
|
4772
|
+
refuse(spath, read_failure(exc), "--uninstall")
|
|
4773
|
+
text = ""
|
|
4774
|
+
found = jsonc_loads(text) or {}
|
|
4775
|
+
old = found.get("statusLine")
|
|
4424
4776
|
new = jsonc_drop(text, "statusLine", old) if "tezgah-statusline" in json.dumps(old or "") else None
|
|
4777
|
+
if found.get(key) is False:
|
|
4778
|
+
want = dict(found)
|
|
4779
|
+
restore_prior(spath, want, key, keep_unrecorded=True)
|
|
4780
|
+
cur = text if new is None else new
|
|
4781
|
+
back = (jsonc_drop(cur, key, False) if key not in want
|
|
4782
|
+
else jsonc_put(cur, key, want[key]) if want[key] is not False else None)
|
|
4783
|
+
new = back if back is not None else new
|
|
4425
4784
|
if new is not None:
|
|
4426
4785
|
write_text(spath, new)
|
|
4427
4786
|
print("%s removed tezgah statusLine from %s" % (OK, spath))
|
|
@@ -4437,13 +4796,10 @@ def uninstall_commandcode():
|
|
|
4437
4796
|
drop_managed_hook_entries(path)
|
|
4438
4797
|
cfg = read_json(path, None)
|
|
4439
4798
|
attr = cfg.get("attribution") if isinstance(cfg, dict) else None
|
|
4440
|
-
# the value install wrote
|
|
4441
|
-
#
|
|
4442
|
-
# apart from it, and dropping it only restores Command Code's default
|
|
4799
|
+
# the value install wrote goes; a value the user had before the install is
|
|
4800
|
+
# put back from the install-prior record (OQ-15)
|
|
4443
4801
|
if isinstance(attr, dict) and attr.get("commit") == "":
|
|
4444
|
-
|
|
4445
|
-
if not attr:
|
|
4446
|
-
cfg.pop("attribution")
|
|
4802
|
+
restore_prior(path, cfg, "attribution/commit")
|
|
4447
4803
|
write_json(path, cfg)
|
|
4448
4804
|
print("%s removed tezgah's attribution.commit from %s" % (OK, path))
|
|
4449
4805
|
drop_if_empty(path)
|
|
@@ -4463,10 +4819,11 @@ def uninstall_copilot():
|
|
|
4463
4819
|
cfg = read_json(spath, None)
|
|
4464
4820
|
if isinstance(cfg, dict):
|
|
4465
4821
|
if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4466
|
-
cfg
|
|
4467
|
-
# the value install wrote;
|
|
4822
|
+
restore_prior(spath, cfg, "statusLine")
|
|
4823
|
+
# the value install wrote goes; one the user had before the install
|
|
4824
|
+
# comes back from the install-prior record (OQ-15)
|
|
4468
4825
|
if cfg.get("includeCoAuthoredBy") is False:
|
|
4469
|
-
cfg
|
|
4826
|
+
restore_prior(spath, cfg, "includeCoAuthoredBy")
|
|
4470
4827
|
if write_json(spath, cfg):
|
|
4471
4828
|
print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
|
|
4472
4829
|
% (OK, spath))
|
|
@@ -4506,7 +4863,7 @@ def uninstall_antigravity():
|
|
|
4506
4863
|
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
4507
4864
|
cfg = read_json(spath, None)
|
|
4508
4865
|
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4509
|
-
cfg
|
|
4866
|
+
restore_prior(spath, cfg, "statusLine")
|
|
4510
4867
|
write_json(spath, cfg)
|
|
4511
4868
|
print("%s removed tezgah's statusLine from %s" % (OK, spath))
|
|
4512
4869
|
drop_if_empty(spath)
|
|
@@ -4553,13 +4910,17 @@ def uninstall_amp():
|
|
|
4553
4910
|
print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
|
|
4554
4911
|
for s in SKILLS:
|
|
4555
4912
|
unlink_tezgah(os.path.join(AMP, "skills", s))
|
|
4556
|
-
spath, settings = amp_settings()
|
|
4913
|
+
spath, settings = amp_settings("--uninstall")
|
|
4557
4914
|
if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
|
|
4558
4915
|
for key in AMP_ATTRIBUTION:
|
|
4559
4916
|
if settings.get(key) is False:
|
|
4560
|
-
|
|
4917
|
+
# a value the user had before the install comes back (OQ-15)
|
|
4918
|
+
restore_prior(spath, settings, key)
|
|
4561
4919
|
write_json(spath, settings)
|
|
4562
4920
|
print("%s removed tezgah's commit-trailer keys from %s" % (OK, spath))
|
|
4921
|
+
# the record goes on every uninstall that read the file: one left behind
|
|
4922
|
+
# would hand a later install's false back as the user's prior value
|
|
4923
|
+
if settings is not None:
|
|
4563
4924
|
drop_if_empty(spath)
|
|
4564
4925
|
strip_managed_md(STATIC_RULES["amp"]())
|
|
4565
4926
|
|
|
@@ -4597,8 +4958,15 @@ def uninstall_omp():
|
|
|
4597
4958
|
if isinstance(cfg, dict):
|
|
4598
4959
|
servers = cfg.get("mcpServers")
|
|
4599
4960
|
if isinstance(servers, dict):
|
|
4600
|
-
for name in [
|
|
4961
|
+
for name in [TEZGAH_MCP_NAME] + app_mcp_names():
|
|
4601
4962
|
servers.pop(name, None)
|
|
4963
|
+
# codegraph goes only as the row install_omp writes: a row of the
|
|
4964
|
+
# user's own (another binary, other args) is theirs, as on the
|
|
4965
|
+
# other hosts
|
|
4966
|
+
argv = graph_command()
|
|
4967
|
+
if servers.get("codegraph") == {"type": "stdio", "command": argv[0],
|
|
4968
|
+
"args": argv[1:], "env": dict(GRAPH_ENV)}:
|
|
4969
|
+
servers.pop("codegraph")
|
|
4602
4970
|
if not servers:
|
|
4603
4971
|
cfg.pop("mcpServers", None)
|
|
4604
4972
|
# $schema was written by install_omp and nothing else: a file left
|
|
@@ -4614,6 +4982,34 @@ def uninstall_omp():
|
|
|
4614
4982
|
strip_managed_md(os.path.join(OMP_AGENT, "RULES.md"))
|
|
4615
4983
|
|
|
4616
4984
|
|
|
4985
|
+
def _under(key, hosts):
|
|
4986
|
+
"""True when the recorded dir `key` ("<dir>/") lies in one of `hosts`'
|
|
4987
|
+
own directories."""
|
|
4988
|
+
roots = [tp.HOST_DIRS[h] for h in hosts if h in tp.HOST_DIRS]
|
|
4989
|
+
roots += [os.path.dirname(AGY_HOOKS)] if "antigravity" in hosts else []
|
|
4990
|
+
roots += [os.path.dirname(GOOSE_PLUGIN)] if "goose" in hosts else []
|
|
4991
|
+
return any(key.startswith(r + os.sep) for r in roots)
|
|
4992
|
+
|
|
4993
|
+
|
|
4994
|
+
def remove_created_dirs(created):
|
|
4995
|
+
"""Remove each directory in `created` (the record's dirs the install made,
|
|
4996
|
+
deepest first) that uninstall left empty. A directory the user made, or one
|
|
4997
|
+
holding anything, stays; the record forgets each dir that is gone (OQ-13)."""
|
|
4998
|
+
gone = []
|
|
4999
|
+
for key in created:
|
|
5000
|
+
d = key[:-1]
|
|
5001
|
+
if os.path.isdir(d) and not os.path.islink(d) and not os.listdir(d):
|
|
5002
|
+
os.rmdir(d)
|
|
5003
|
+
print("%s removed %s (empty)" % (OK, d))
|
|
5004
|
+
if not os.path.lexists(d):
|
|
5005
|
+
gone.append(key)
|
|
5006
|
+
if gone and os.path.exists(PRIOR): # a full run already took the record
|
|
5007
|
+
store = read_json(PRIOR, {})
|
|
5008
|
+
for key in gone:
|
|
5009
|
+
store.pop(key, None)
|
|
5010
|
+
_prior_save(store)
|
|
5011
|
+
|
|
5012
|
+
|
|
4617
5013
|
UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
|
|
4618
5014
|
"opencode": uninstall_opencode, "cursor": uninstall_cursor,
|
|
4619
5015
|
"dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
|
|
@@ -4675,12 +5071,32 @@ def uninstall(hosts, dry_run=False):
|
|
|
4675
5071
|
print("uninstalling for: %s (%s run)\n"
|
|
4676
5072
|
% (", ".join(hosts) or "none detected", "full" if full else "partial"))
|
|
4677
5073
|
UNINSTALLING = tuple(hosts)
|
|
5074
|
+
print("\ncommon:")
|
|
5075
|
+
if full:
|
|
5076
|
+
unlink_dir(tp.BIN_DIR)
|
|
5077
|
+
else:
|
|
5078
|
+
# the farm is shared: every host still armed runs its hook, the gate and
|
|
5079
|
+
# the status line from it, so a partial run takes only the hook links of
|
|
5080
|
+
# the hosts it removes (B-007: emptying it disarmed the hosts that stay)
|
|
5081
|
+
for h in hosts:
|
|
5082
|
+
unlink_tezgah(os.path.join(tp.BIN_DIR, "tezgah-%s-hook" % h))
|
|
4678
5083
|
uninstall_common()
|
|
4679
5084
|
for h in hosts:
|
|
4680
5085
|
UNINSTALLERS[h]()
|
|
4681
5086
|
if "codex" in hosts and "cursor" in (armed or []) and "cursor" not in hosts:
|
|
4682
5087
|
print("\ncursor (its skills came through ~/.codex/skills):")
|
|
4683
5088
|
link_cursor_skills()
|
|
5089
|
+
# every directory the install created goes once empty, deepest first; the
|
|
5090
|
+
# list is read now, because a full run takes the record with the config
|
|
5091
|
+
created = sorted((k for k, v in read_json(PRIOR, {}).items()
|
|
5092
|
+
if k.endswith(os.sep) and isinstance(v, dict)
|
|
5093
|
+
and v.get("created") is True), key=len, reverse=True)
|
|
5094
|
+
if not full:
|
|
5095
|
+
# a partial run takes only dirs under the removed hosts' own roots; a
|
|
5096
|
+
# shared one (the caches, ~/.config) or an armed host's stays recorded
|
|
5097
|
+
# until the full run
|
|
5098
|
+
rest = set(armed) - set(hosts)
|
|
5099
|
+
created = [k for k in created if _under(k, hosts) and not _under(k, rest)]
|
|
4684
5100
|
if "claude" in hosts:
|
|
4685
5101
|
print("\nclaude plugin copy:")
|
|
4686
5102
|
remove_plugin_copies()
|
|
@@ -4699,6 +5115,7 @@ def uninstall(hosts, dry_run=False):
|
|
|
4699
5115
|
write_json(tp.CONFIG, cfg)
|
|
4700
5116
|
print("\n%s still armed: %s keeps its config and the install tree"
|
|
4701
5117
|
% (NO, ", ".join(rest)))
|
|
5118
|
+
remove_created_dirs(created)
|
|
4702
5119
|
remaining = verify_uninstall(hosts, full)
|
|
4703
5120
|
print("\nrestart each host so in-memory wiring unloads.")
|
|
4704
5121
|
pred = predecessors()
|
|
@@ -6034,10 +6451,9 @@ def host_checks_amp():
|
|
|
6034
6451
|
plugin = fh.read()
|
|
6035
6452
|
except OSError:
|
|
6036
6453
|
plugin = ""
|
|
6037
|
-
#
|
|
6038
|
-
settings = (
|
|
6039
|
-
|
|
6040
|
-
else read_jsonc(os.path.join(AMP, "settings.jsonc")) or {})
|
|
6454
|
+
# the file install and uninstall resolve, read with comments tolerated
|
|
6455
|
+
spath, settings = amp_settings(None)
|
|
6456
|
+
settings, name = settings or {}, os.path.basename(spath)
|
|
6041
6457
|
return [
|
|
6042
6458
|
# byte for byte, as omp's bridge row: a hand edit or an older tree's
|
|
6043
6459
|
# render is not the wiring this tree arms
|
|
@@ -6045,9 +6461,9 @@ def host_checks_amp():
|
|
|
6045
6461
|
bool(plugin) and plugin == ta.amp_plugin(HERE)),
|
|
6046
6462
|
("skills linked", skills_linked(os.path.join(AMP, "skills"))),
|
|
6047
6463
|
("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
|
|
6048
|
-
("%s off in
|
|
6464
|
+
("%s off in %s" % (AMP_ATTRIBUTION[0], name),
|
|
6049
6465
|
settings.get(AMP_ATTRIBUTION[0]) is False),
|
|
6050
|
-
("%s off in
|
|
6466
|
+
("%s off in %s" % (AMP_ATTRIBUTION[1], name),
|
|
6051
6467
|
settings.get(AMP_ATTRIBUTION[1]) is False),
|
|
6052
6468
|
("amp CLI on PATH", bool(tp.which_user("amp"))),
|
|
6053
6469
|
]
|
|
@@ -6889,6 +7305,9 @@ def upgrade(version, dry_run):
|
|
|
6889
7305
|
return 127
|
|
6890
7306
|
import tezgah_update
|
|
6891
7307
|
old = tezgah_update.installed_entries(hosts)
|
|
7308
|
+
# flushed first: through a pipe the plan sat in the buffer and printed after
|
|
7309
|
+
# the children's own output (B-099)
|
|
7310
|
+
sys.stdout.flush()
|
|
6892
7311
|
proc = subprocess.run(cmd)
|
|
6893
7312
|
if proc.returncode != 0:
|
|
6894
7313
|
print("%s %s exited %d; tezgah was not re-armed"
|
|
@@ -6912,6 +7331,7 @@ def upgrade(version, dry_run):
|
|
|
6912
7331
|
print("%s fetched, not re-armed: %s. Re-arm later with `%s`"
|
|
6913
7332
|
% (NO, tezgah_update.NOT_REARMED["prefix"], " ".join(argv)))
|
|
6914
7333
|
return 1
|
|
7334
|
+
sys.stdout.flush()
|
|
6915
7335
|
return subprocess.run(argv).returncode
|
|
6916
7336
|
|
|
6917
7337
|
|
|
@@ -6981,10 +7401,27 @@ def install_all(hosts, new_roots, deps_off, dry_run, redetect):
|
|
|
6981
7401
|
return 1 if missed or REFUSED else 0
|
|
6982
7402
|
|
|
6983
7403
|
|
|
7404
|
+
def python_floor(version):
|
|
7405
|
+
"""The refusal for an interpreter below the Python 3.10 floor the README
|
|
7406
|
+
states (OQ-16: CI tests nothing older), or None. `version` is a
|
|
7407
|
+
sys.version_info-shaped tuple, so the check runs without that interpreter."""
|
|
7408
|
+
if tuple(version[:2]) >= (3, 10):
|
|
7409
|
+
return None
|
|
7410
|
+
return ("tezgah needs Python 3.10 or newer; %s is %s"
|
|
7411
|
+
% (sys.executable, ".".join(str(p) for p in version[:3])))
|
|
7412
|
+
|
|
7413
|
+
|
|
6984
7414
|
def main(argv):
|
|
7415
|
+
why = python_floor(sys.version_info)
|
|
7416
|
+
if why:
|
|
7417
|
+
sys.stderr.write("tezgah-setup: %s\n" % why)
|
|
7418
|
+
return 1
|
|
7419
|
+
# no prefix matching: `--upgrade V --vers` read `--vers` as `--version`,
|
|
7420
|
+
# printed it and moved nothing, past the refusal below (B-023)
|
|
6985
7421
|
ap = argparse.ArgumentParser(description="arm tezgah in every installed host; "
|
|
6986
7422
|
"`tezgah update [--dry-run]` moves this install "
|
|
6987
|
-
"to the newest release and re-arms it"
|
|
7423
|
+
"to the newest release and re-arms it",
|
|
7424
|
+
allow_abbrev=False)
|
|
6988
7425
|
ap.add_argument("--install", action="store_true")
|
|
6989
7426
|
ap.add_argument("--wizard", action="store_true",
|
|
6990
7427
|
help="ask the install questions (a bare run on a terminal does this)")
|
|
@@ -7117,6 +7554,8 @@ def main(argv):
|
|
|
7117
7554
|
paths = refresh_contract()
|
|
7118
7555
|
for path in paths:
|
|
7119
7556
|
print("%s refreshed %s" % (OK, path))
|
|
7557
|
+
if REFUSED:
|
|
7558
|
+
return 1 # each refused file was named above; it is not current
|
|
7120
7559
|
if not paths:
|
|
7121
7560
|
print("contract is current")
|
|
7122
7561
|
return 0
|
|
@@ -7161,8 +7600,8 @@ def main(argv):
|
|
|
7161
7600
|
wizard_mode = (args.wizard or bare_tty) and not args.report
|
|
7162
7601
|
|
|
7163
7602
|
if args.uninstall:
|
|
7164
|
-
|
|
7165
|
-
return
|
|
7603
|
+
# 1 while anything tezgah wrote survives: the verify pass counts it
|
|
7604
|
+
return uninstall(hosts, dry_run=args.dry_run)
|
|
7166
7605
|
|
|
7167
7606
|
# A selection change is its own command: it persists the choice and brings
|
|
7168
7607
|
# every host the install recorded along, so `--enable`/`--disable` reach the
|