@r1z4x/tezgah 1.11.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 +507 -1
- package/MANIFEST +40 -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 +18 -10
- 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 +158 -42
- 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 +51 -10
- package/bin/tezgah-rollback +3 -0
- package/bin/tezgah-route +1 -1
- package/bin/tezgah-setup +1741 -95
- package/bin/tezgah-status +11 -1
- package/bin/tezgah-task +58 -17
- 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 +12 -6
- package/docs/contract.md +83 -34
- package/docs/environment.md +59 -24
- package/docs/evidence.md +128 -25
- package/docs/gate.md +77 -23
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +343 -48
- package/docs/layers.md +2 -2
- package/docs/models.md +6 -0
- package/docs/operations.md +83 -29
- package/docs/research.md +37 -11
- package/docs/skills.md +31 -8
- package/docs/status-line.md +39 -23
- package/docs/testing.md +50 -17
- package/docs/vision.md +6 -2
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +16 -2
- package/hooks/lang/tr.json +14 -2
- package/hooks/projects-posttooluse.py +11 -3
- package/hooks/projects-stop.py +3 -2
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +61 -31
- package/hooks/tezgah_gate.py +480 -126
- package/hooks/tezgah_integrity.py +577 -133
- package/hooks/tezgah_lessons.py +5 -2
- package/hooks/tezgah_paths.py +60 -8
- package/hooks/tezgah_policy.py +48 -18
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_skill_pick.py +1 -1
- package/hooks/tezgah_snapshot.py +6 -3
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_stopspec.py +2 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +396 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +3 -2
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +4 -2
- package/hosts/droid/hook.py +259 -0
- package/hosts/dsh/hooks.json +1 -1
- package/hosts/gemini/hook.py +213 -0
- package/hosts/goose/hook.py +145 -0
- package/hosts/omp/hook.py +5 -2
- package/hosts/omp/tezgah-hook.ts.in +157 -42
- package/hosts/opencode/plugins/tezgah.js +209 -63
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +11 -6
- 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/harness/SKILL.md +2 -0
- package/skills/i-have-adhd/SKILL.md +10 -1
- package/skills/plan-add/SKILL.md +5 -1
- package/skills/plan-add/render_table.py +7 -0
- package/skills/pm-frameworks/LICENSE +21 -0
- package/skills/research/SKILL.md +22 -9
- package/skills/rl-env/LICENSE +201 -0
- package/skills/tezgah-contract/SKILL.md +21 -8
- package/statusline.py +20 -9
- 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/workflows/graph-review.js +14 -2
package/bin/tezgah-setup
CHANGED
|
@@ -166,6 +166,28 @@ DSH_WEB_PATCH = os.path.join(DSH_WEB, "cordis.patch.yml")
|
|
|
166
166
|
DSH_STATUSLINE = "tezgah-dsh-statusline"
|
|
167
167
|
OMP = tp.HOST_DIRS["omp"]
|
|
168
168
|
OMP_AGENT = os.path.join(OMP, "agent")
|
|
169
|
+
QWEN = tp.HOST_DIRS["qwen"]
|
|
170
|
+
DROID = tp.HOST_DIRS["droid"]
|
|
171
|
+
COMMANDCODE = tp.HOST_DIRS["commandcode"]
|
|
172
|
+
COPILOT = tp.HOST_DIRS["copilot"]
|
|
173
|
+
GEMINI = tp.HOST_DIRS["gemini"]
|
|
174
|
+
# tezgah's own Gemini extension: a dir Gemini loads from <home>/.gemini/extensions
|
|
175
|
+
# with no `gemini extensions install` (extension-manager.ts reads every subdir;
|
|
176
|
+
# only a non-empty `security.allowedExtensions` asks for install metadata)
|
|
177
|
+
GEMINI_EXT = os.path.join(GEMINI, "extensions", "tezgah")
|
|
178
|
+
ANTIGRAVITY = tp.HOST_DIRS["antigravity"]
|
|
179
|
+
# Antigravity's global hooks file: one top-level name per hook set, and the
|
|
180
|
+
# Antigravity App and IDE load it too (docs/hooks); tezgah owns the `tezgah` name
|
|
181
|
+
AGY_HOOKS = os.path.join(os.path.dirname(ANTIGRAVITY), "config", "hooks.json")
|
|
182
|
+
GOOSE = tp.HOST_DIRS["goose"]
|
|
183
|
+
# tezgah's own Goose plugin under ~/.agents/plugins, wholly tezgah's
|
|
184
|
+
GOOSE_PLUGIN = tp.GOOSE_PLUGIN
|
|
185
|
+
AMP = tp.HOST_DIRS["amp"]
|
|
186
|
+
# tezgah's Amp system plugin, one file Amp loads with every other .ts there
|
|
187
|
+
AMP_PLUGIN = tp.AMP_PLUGIN
|
|
188
|
+
# Amp's commit trailers, both on by default (docs cli/settings): the co-author
|
|
189
|
+
# line and the thread link; tezgah turns both off unless the user set them
|
|
190
|
+
AMP_ATTRIBUTION = ("amp.git.commit.coauthor.enabled", "amp.git.commit.ampThread.enabled")
|
|
169
191
|
OMP_MCP_SCHEMA = ("https://raw.githubusercontent.com/can1357/oh-my-pi/main/"
|
|
170
192
|
"packages/coding-agent/src/config/mcp-schema.json")
|
|
171
193
|
# App-analysis servers omp drops on its own. While its native `browser` (the eval
|
|
@@ -223,7 +245,8 @@ OPENCODE_CLI_PLUGIN = os.path.join(OPENCODE, "tui-plugins", "tezgah-cli")
|
|
|
223
245
|
|
|
224
246
|
|
|
225
247
|
WORKFLOW_FILES = ["graph-map.js", "graph-review.js", "graph-impact.js"]
|
|
226
|
-
ALL_HOSTS = ["claude", "codex", "opencode", "cursor", "dsh", "omp"
|
|
248
|
+
ALL_HOSTS = ["claude", "codex", "opencode", "cursor", "dsh", "omp", "qwen", "droid",
|
|
249
|
+
"commandcode", "copilot", "gemini", "antigravity", "goose", "amp"]
|
|
227
250
|
# orx ships an installable skill shim for four agents; dsh has no orx harness,
|
|
228
251
|
# so research routing there falls back to `orx skill` on the shell. omp has no
|
|
229
252
|
# orx target of its own, and needs none: orx's `codex` target writes its shim to
|
|
@@ -239,6 +262,11 @@ SKILL_ROUTER = os.path.join(tp.CONFIG_DIR, "opencode-skills.md")
|
|
|
239
262
|
# Where `link()` records the entries it had to materialise instead of
|
|
240
263
|
# symlinking; see materialised_paths() for why the fact has to be written down.
|
|
241
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"
|
|
242
270
|
# Every kill switch the code reads (`off("...")` under hooks/ and bin/;
|
|
243
271
|
# `bin/tezgah-docs --citations` checks the two agree). The canonical
|
|
244
272
|
# copies die with ~/.config/tezgah; the pre-multi-host setup wrote them to
|
|
@@ -257,7 +285,8 @@ OFF_SWITCH_NAMES = ("adhd-off", "agents-off", "consult-off", "design-off", "docs
|
|
|
257
285
|
# moved aside - the only copy of them on disk.
|
|
258
286
|
GENERATED_STATE = frozenset((
|
|
259
287
|
"bin", "config.json", "contract.sha256", "contract.sha256.lock",
|
|
260
|
-
"contract.sha256.tmp", "install.
|
|
288
|
+
"contract.sha256.tmp", "install-prior.json", "install-prior.json.tmp",
|
|
289
|
+
"install.log", "materialised",
|
|
261
290
|
"opencode-contract.md", "opencode-skills.md", "opencode-skills.full.md",
|
|
262
291
|
"ponytail.level", "state"))
|
|
263
292
|
# The predecessor harness's marker in an AGENTS.md under a root: the block tells
|
|
@@ -324,6 +353,73 @@ CURSOR_HOOK_EVENTS = {
|
|
|
324
353
|
"afterFileEdit": {"timeout": 10},
|
|
325
354
|
"afterAgentResponse": {"timeout": 5},
|
|
326
355
|
}
|
|
356
|
+
# Qwen Code 0.21.13: a matcher is a regex over the runtime tool id, and a
|
|
357
|
+
# command hook's `timeout` is in MILLISECONDS (60000 by default) - the seconds
|
|
358
|
+
# value the other manifests carry would time every hook out. Stop and
|
|
359
|
+
# SubagentStop run the integrity rule, which may ask a judge, so they get more.
|
|
360
|
+
# Qwen takes MCP tools as `mcp__<server>__<tool>`.
|
|
361
|
+
QWEN_PRETOOL_MATCHER = ("^(run_shell_command|monitor|write_file|edit|replace|notebook_edit|"
|
|
362
|
+
"agent|grep_search|mcp__.*)$")
|
|
363
|
+
QWEN_HOOK_EVENTS = (("SessionStart", None, 5000), ("UserPromptSubmit", None, 5000),
|
|
364
|
+
("PreToolUse", QWEN_PRETOOL_MATCHER, 5000),
|
|
365
|
+
("PostToolUse", None, 5000), ("PostToolUseFailure", None, 5000),
|
|
366
|
+
("SubagentStart", None, 5000), ("SubagentStop", None, 30000),
|
|
367
|
+
("Stop", None, 30000))
|
|
368
|
+
# Factory Droid 0.251.0 (docs.factory.com harness/hooks): a matcher is a
|
|
369
|
+
# case-sensitive regex over the tool id, and `timeout` is in seconds. Droid has
|
|
370
|
+
# no SubagentStart and no failure event. MCP tools are `mcp__<server>__<tool>`.
|
|
371
|
+
DROID_PRETOOL_MATCHER = "^(Execute|Create|Edit|ApplyPatch|Task|Grep|mcp__.*)$"
|
|
372
|
+
DROID_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
373
|
+
("PreToolUse", DROID_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
|
|
374
|
+
("SubagentStop", None, 30), ("Stop", None, 30))
|
|
375
|
+
# Command Code (commandcode.ai/docs/hooks): four events, `timeout` in seconds, and
|
|
376
|
+
# a matcher tested against the tool's display name (SHELL, READ, WRITE, EDIT) -
|
|
377
|
+
# its settings hooks see no other tool, so MCP, subagent and web calls are not
|
|
378
|
+
# gated there. A matcher on SessionStart or Stop stops the hook from firing.
|
|
379
|
+
COMMANDCODE_PRETOOL_MATCHER = "shell|write|edit"
|
|
380
|
+
COMMANDCODE_HOOK_EVENTS = (("SessionStart", None, 5),
|
|
381
|
+
("PreToolUse", COMMANDCODE_PRETOOL_MATCHER, 5),
|
|
382
|
+
("PostToolUse", None, 5), ("Stop", None, 30))
|
|
383
|
+
# GitHub Copilot CLI (docs.github.com hooks-reference): tezgah's own file in the
|
|
384
|
+
# user hooks dir, flat entries with `timeoutSec` in seconds. The PascalCase names
|
|
385
|
+
# get snake_case payloads and Claude tool names; `subagentStart` has no PascalCase
|
|
386
|
+
# form, so its camelCase payload names no event and the command passes it. A
|
|
387
|
+
# PascalCase PreToolUse matcher is anchored and tested against the Claude tool
|
|
388
|
+
# name; an MCP tool is `<server>-<tool>`. A timed-out PreToolUse lets the call
|
|
389
|
+
# through, hence 10 s there.
|
|
390
|
+
COPILOT_PRETOOL_MATCHER = "^(?:Bash|Write|Edit|Agent|Task|Grep|.+-.+)$"
|
|
391
|
+
COPILOT_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
392
|
+
("PreToolUse", COPILOT_PRETOOL_MATCHER, 10), ("PostToolUse", None, 5),
|
|
393
|
+
("PostToolUseFailure", None, 5), ("subagentStart", None, 5),
|
|
394
|
+
("SubagentStop", None, 30), ("Stop", None, 30))
|
|
395
|
+
# Gemini CLI (geminicli hooks reference): `timeout` in MILLISECONDS, a BeforeTool
|
|
396
|
+
# matcher is a regex over the tool name, a lifecycle matcher an exact string (so
|
|
397
|
+
# none). A subagent is a tool of its own name; an MCP tool is
|
|
398
|
+
# `mcp_<server>_<tool>`. AfterAgent runs the integrity rule, which may ask a judge.
|
|
399
|
+
GEMINI_PRETOOL_MATCHER = ("^(run_shell_command|write_file|replace|grep_search|"
|
|
400
|
+
"search_file_content|codebase_investigator|generalist|"
|
|
401
|
+
"invoke_agent|mcp_.*)$")
|
|
402
|
+
GEMINI_HOOK_EVENTS = (("SessionStart", None, 5000), ("BeforeAgent", None, 5000),
|
|
403
|
+
("BeforeTool", GEMINI_PRETOOL_MATCHER, 5000),
|
|
404
|
+
("AfterTool", None, 5000), ("AfterAgent", None, 30000))
|
|
405
|
+
# Antigravity CLI (docs/hooks): `timeout` in seconds; a tool event is a matcher
|
|
406
|
+
# group (a regex over the tool name, "" for every tool), a lifecycle event a bare
|
|
407
|
+
# handler. The payload names no event, so the command does (argv[1]). An MCP
|
|
408
|
+
# tool's name is undocumented, so none is matched. Stop runs the integrity rule.
|
|
409
|
+
ANTIGRAVITY_PRETOOL_MATCHER = ("^(run_command|write_to_file|replace_file_content|"
|
|
410
|
+
"multi_replace_file_content|invoke_subagent|grep_search)$")
|
|
411
|
+
ANTIGRAVITY_HOOK_EVENTS = (("SessionStart", None, 5), ("PreInvocation", None, 5),
|
|
412
|
+
("PreToolUse", ANTIGRAVITY_PRETOOL_MATCHER, 5),
|
|
413
|
+
("PostToolUse", "", 5), ("Stop", None, 30))
|
|
414
|
+
# Goose (docs guides/context-engineering/hooks): `timeout` in seconds, a matcher a
|
|
415
|
+
# regex tested unanchored (so anchored here) against the tool name. The built-in
|
|
416
|
+
# developer tools are bare (`shell`, `write`, `edit`), an extension's tool is
|
|
417
|
+
# `<extension>__<tool>`. Only PreToolUse and Stop can block; Stop runs the
|
|
418
|
+
# integrity rule. `on_failure` is left at its default `allow` (X-D2).
|
|
419
|
+
GOOSE_PRETOOL_MATCHER = "^(shell|write|edit|.+__.+)$"
|
|
420
|
+
GOOSE_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
421
|
+
("PreToolUse", GOOSE_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
|
|
422
|
+
("PostToolUseFailure", None, 5), ("Stop", None, 30))
|
|
327
423
|
# The feature selection for THIS run: None means "read config.json['features']",
|
|
328
424
|
# and an absent key there means the registry's declared default, which is what
|
|
329
425
|
# makes a config written before this existed behave exactly as it used to.
|
|
@@ -338,7 +434,20 @@ OK, NO = " ok ", " MISS "
|
|
|
338
434
|
|
|
339
435
|
|
|
340
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)
|
|
341
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)
|
|
342
451
|
|
|
343
452
|
|
|
344
453
|
def expand(p):
|
|
@@ -468,14 +577,22 @@ def hook_entries(hosts):
|
|
|
468
577
|
what re-arming would change."""
|
|
469
578
|
out = {}
|
|
470
579
|
for h in hosts:
|
|
471
|
-
if h in ("codex", "cursor"
|
|
472
|
-
|
|
580
|
+
if h in ("codex", "cursor", "qwen", "droid", "commandcode", "copilot", "gemini",
|
|
581
|
+
"antigravity", "goose"):
|
|
582
|
+
groups = {"codex": codex_hook_groups, "cursor": cursor_hook_entries,
|
|
583
|
+
"qwen": qwen_hook_groups, "droid": droid_hook_groups,
|
|
584
|
+
"commandcode": commandcode_hook_groups,
|
|
585
|
+
"copilot": copilot_hook_entries, "gemini": gemini_hook_groups,
|
|
586
|
+
"antigravity": antigravity_hook_groups,
|
|
587
|
+
"goose": goose_hook_groups}[h]()
|
|
473
588
|
out[h] = ta.own_groups({"hooks": {ev: [g] for ev, g in groups.items()}})
|
|
474
589
|
elif h == "opencode":
|
|
475
590
|
src = os.path.join(HERE, "hosts", "opencode", "plugins", "tezgah.js")
|
|
476
591
|
out[h] = {d: src for d in ta.OPENCODE_DIRS}
|
|
477
592
|
elif h == "omp":
|
|
478
593
|
out[h] = {"bridge": omp_bridge()}
|
|
594
|
+
elif h == "amp":
|
|
595
|
+
out[h] = {"plugin": ta.amp_plugin(HERE)}
|
|
479
596
|
else:
|
|
480
597
|
out[h] = ta.registration(h, HERE)[0] or {}
|
|
481
598
|
return out
|
|
@@ -559,6 +676,132 @@ def record_materialised(path, add=True):
|
|
|
559
676
|
pass
|
|
560
677
|
|
|
561
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
|
+
|
|
562
805
|
def is_tezgah_copy(path):
|
|
563
806
|
"""True when `path` is a real entry this installer materialised where a
|
|
564
807
|
symlink was refused. A same-named file tezgah never wrote is the user's."""
|
|
@@ -647,39 +890,98 @@ def read_json(path, default):
|
|
|
647
890
|
return data
|
|
648
891
|
|
|
649
892
|
|
|
650
|
-
# Every host
|
|
893
|
+
# Every host file this run refused to rewrite; a non-empty list fails it.
|
|
651
894
|
REFUSED = []
|
|
652
895
|
|
|
653
896
|
|
|
654
|
-
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"):
|
|
655
915
|
"""A host config tezgah is about to rewrite: `empty` when the file is absent,
|
|
656
916
|
its object when it parses as one, None otherwise - named, recorded in
|
|
657
917
|
REFUSED, and never written, because a write would replace the user's file
|
|
658
|
-
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."""
|
|
659
921
|
try:
|
|
660
922
|
with open(path, encoding="utf-8") as fh:
|
|
661
|
-
|
|
923
|
+
text = fh.read()
|
|
924
|
+
data = json.loads(text)
|
|
662
925
|
except FileNotFoundError:
|
|
663
926
|
return empty
|
|
664
927
|
except (OSError, ValueError) as exc:
|
|
665
|
-
|
|
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)
|
|
666
933
|
else:
|
|
667
934
|
if isinstance(data, dict):
|
|
668
935
|
return data
|
|
669
936
|
why = "not a JSON object: the top level is %s" % type(data).__name__
|
|
670
|
-
|
|
671
|
-
% (NO, path, why))
|
|
672
|
-
REFUSED.append(path)
|
|
937
|
+
refuse(path, why, rerun)
|
|
673
938
|
return None
|
|
674
939
|
|
|
675
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
|
+
|
|
676
962
|
def write_json(path, data):
|
|
677
|
-
"""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.
|
|
678
965
|
|
|
679
966
|
An unchanged write is not free: it moves a host file's mtime and lays down a
|
|
680
967
|
`.tezgah-bak`, so a command whose whole point is idempotence (a repeated
|
|
681
|
-
--disable) would report a change it never made.
|
|
682
|
-
|
|
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 ""))
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def write_text(path, text):
|
|
982
|
+
"""write_json's write for text already rendered (a JSON-with-comments file
|
|
983
|
+
edited in place): unchanged bytes skip it, a backup comes first, and the
|
|
984
|
+
file keeps its mode. Returns True when it wrote."""
|
|
683
985
|
try:
|
|
684
986
|
with open(path, encoding="utf-8") as fh:
|
|
685
987
|
if fh.read() == text:
|
|
@@ -689,8 +991,15 @@ def write_json(path, data):
|
|
|
689
991
|
mkdirp(os.path.dirname(path))
|
|
690
992
|
backup(path)
|
|
691
993
|
tmp = path + ".tmp"
|
|
692
|
-
|
|
994
|
+
existed = os.path.exists(path)
|
|
995
|
+
# over an existing file the copy is born owner-only, then takes the file's
|
|
996
|
+
# mode: a settings file holding provider keys is never readable by others,
|
|
997
|
+
# not even between the write and the mode copy
|
|
998
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if existed else 0o666)
|
|
999
|
+
with open(fd, "w", encoding="utf-8") as fh:
|
|
693
1000
|
fh.write(text)
|
|
1001
|
+
if existed:
|
|
1002
|
+
shutil.copymode(path, tmp) # an owner-only config stays owner-only
|
|
694
1003
|
os.replace(tmp, path)
|
|
695
1004
|
return True
|
|
696
1005
|
|
|
@@ -727,7 +1036,8 @@ def backup_base(path):
|
|
|
727
1036
|
|
|
728
1037
|
def backup(path):
|
|
729
1038
|
"""Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
|
|
730
|
-
changes it
|
|
1039
|
+
changes it, owner-only: a host config can hold provider keys (Qwen's
|
|
1040
|
+
settings.json does), and a copy must not be readable where the file was not.
|
|
731
1041
|
|
|
732
1042
|
The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
|
|
733
1043
|
know it; past BACKUP_CAP the middle copies go, never the oldest."""
|
|
@@ -743,6 +1053,7 @@ def backup(path):
|
|
|
743
1053
|
try:
|
|
744
1054
|
mkdirp(os.path.dirname(dst))
|
|
745
1055
|
shutil.copy2(path, dst)
|
|
1056
|
+
os.chmod(dst, 0o600)
|
|
746
1057
|
d, name = os.path.split(base)
|
|
747
1058
|
old = sorted(entry for entry in os.listdir(d or ".")
|
|
748
1059
|
if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
|
|
@@ -813,6 +1124,7 @@ def set_managed_block(path, block, start="# tezgah:start", end="# tezgah:end"):
|
|
|
813
1124
|
out.pop()
|
|
814
1125
|
if not out:
|
|
815
1126
|
out = ["# dsh home patch layer - tezgah-setup manages the marked block below"]
|
|
1127
|
+
note_created(path)
|
|
816
1128
|
mkdirp(os.path.dirname(path))
|
|
817
1129
|
text = "\n".join(out) + "\n" + start + " (managed by tezgah-setup; do not edit)\n" \
|
|
818
1130
|
+ block.strip() + "\n" + end + "\n"
|
|
@@ -1076,7 +1388,13 @@ def install_common(new_roots, hosts):
|
|
|
1076
1388
|
# older one, so an upgrade changes no one's reply language
|
|
1077
1389
|
cfg["language"] = tp.env_language() if first else "tr"
|
|
1078
1390
|
if own:
|
|
1079
|
-
|
|
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))
|
|
1080
1398
|
print("%s %s roots=%s" % (OK, tp.CONFIG, cfg["roots"]))
|
|
1081
1399
|
for name, src in [
|
|
1082
1400
|
("consult", os.path.join(HERE, "bin", "consult")),
|
|
@@ -1089,6 +1407,9 @@ def install_common(new_roots, hosts):
|
|
|
1089
1407
|
("tezgah-rollback", os.path.join(HERE, "bin", "tezgah-rollback")),
|
|
1090
1408
|
("tezgah-agents", os.path.join(HERE, "bin", "tezgah-agents")),
|
|
1091
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")),
|
|
1092
1413
|
("tezgah-capture", os.path.join(HERE, "bin", "tezgah-capture")),
|
|
1093
1414
|
("tezgah-doctor", os.path.join(HERE, "bin", "tezgah-doctor")),
|
|
1094
1415
|
("tezgah-setup", os.path.join(HERE, "bin", "tezgah-setup")),
|
|
@@ -1098,6 +1419,15 @@ def install_common(new_roots, hosts):
|
|
|
1098
1419
|
("tezgah-render-table", os.path.join(HERE, "skills", "plan-add", "render_table.py")),
|
|
1099
1420
|
("tezgah-codex-hook", os.path.join(HERE, "hosts", "codex", "hook.py")),
|
|
1100
1421
|
("tezgah-cursor-hook", os.path.join(HERE, "hosts", "cursor", "hook.py")),
|
|
1422
|
+
("tezgah-qwen-hook", os.path.join(HERE, "hosts", "qwen", "hook.py")),
|
|
1423
|
+
("tezgah-droid-hook", os.path.join(HERE, "hosts", "droid", "hook.py")),
|
|
1424
|
+
("tezgah-commandcode-hook",
|
|
1425
|
+
os.path.join(HERE, "hosts", "commandcode", "hook.py")),
|
|
1426
|
+
("tezgah-copilot-hook", os.path.join(HERE, "hosts", "copilot", "hook.py")),
|
|
1427
|
+
("tezgah-gemini-hook", os.path.join(HERE, "hosts", "gemini", "hook.py")),
|
|
1428
|
+
("tezgah-antigravity-hook",
|
|
1429
|
+
os.path.join(HERE, "hosts", "antigravity", "hook.py")),
|
|
1430
|
+
("tezgah-goose-hook", os.path.join(HERE, "hosts", "goose", "hook.py")),
|
|
1101
1431
|
("tezgah-statusline", os.path.join(HERE, "statusline.py")),
|
|
1102
1432
|
("tezgah-triage", os.path.join(HERE, "bin", "tezgah-triage")),
|
|
1103
1433
|
("tezgah-migrate", os.path.join(HERE, "bin", "tezgah-migrate")),
|
|
@@ -1192,7 +1522,7 @@ def install_claude():
|
|
|
1192
1522
|
# tracked project file. Claude's hook drops the core when the block is there
|
|
1193
1523
|
# (`TEZGAH_CORE_IN_FILE`, hooks/projects-auto-init.py), so a session pays for
|
|
1194
1524
|
# the contract once.
|
|
1195
|
-
|
|
1525
|
+
write_rules("claude")
|
|
1196
1526
|
write_user_agents("claude")
|
|
1197
1527
|
register_claude_plugin()
|
|
1198
1528
|
|
|
@@ -1355,7 +1685,8 @@ def wire_claude_attribution():
|
|
|
1355
1685
|
|
|
1356
1686
|
def wire_claude_statusline():
|
|
1357
1687
|
"""Point Claude's statusLine at tezgah's wrapper (it forwards to Orca, then
|
|
1358
|
-
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."""
|
|
1359
1690
|
sp = os.path.join(CLAUDE, "settings.json")
|
|
1360
1691
|
settings = read_json(sp, None)
|
|
1361
1692
|
if settings is None:
|
|
@@ -1365,6 +1696,8 @@ def wire_claude_statusline():
|
|
|
1365
1696
|
if (settings.get("statusLine") or {}).get("command") == cmd:
|
|
1366
1697
|
print("%s statusLine -> tezgah" % OK)
|
|
1367
1698
|
return
|
|
1699
|
+
if "statusline.py" not in json.dumps(settings.get("statusLine") or {}):
|
|
1700
|
+
remember_prior(sp, settings, "statusLine")
|
|
1368
1701
|
settings["statusLine"] = {"type": "command", "command": cmd}
|
|
1369
1702
|
write_json(sp, settings)
|
|
1370
1703
|
print("%s statusLine -> tezgah (previous kept as %s.<timestamp>.tezgah-bak)" % (OK, sp))
|
|
@@ -1411,7 +1744,7 @@ def install_codex():
|
|
|
1411
1744
|
# SessionStart hook's context is one message, where this file is the
|
|
1412
1745
|
# instructions Codex keeps for the whole session. Global, like omp's
|
|
1413
1746
|
# RULES.md, because a per-project file would be a tracked project file.
|
|
1414
|
-
|
|
1747
|
+
write_rules("codex")
|
|
1415
1748
|
write_user_agents("codex")
|
|
1416
1749
|
|
|
1417
1750
|
|
|
@@ -1536,9 +1869,11 @@ def strip_toml_mcp(path, names):
|
|
|
1536
1869
|
return False
|
|
1537
1870
|
out, skip, changed = [], False, False
|
|
1538
1871
|
for line in lines:
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
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
|
|
1542
1877
|
changed = changed or skip
|
|
1543
1878
|
if not skip:
|
|
1544
1879
|
out.append(line)
|
|
@@ -1597,8 +1932,12 @@ def write_artifact(path, text, write):
|
|
|
1597
1932
|
|
|
1598
1933
|
The writers are idempotent (write_if_changed, set_managed_md): a re-render
|
|
1599
1934
|
that produces the same bytes moves no mtime and lays no backup. Returns True
|
|
1600
|
-
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)
|
|
1601
1938
|
changed = write(path, text)
|
|
1939
|
+
if len(REFUSED) > refused:
|
|
1940
|
+
return None
|
|
1602
1941
|
if recorded_shas().get(path) != _sha(text):
|
|
1603
1942
|
record_contract(path, text)
|
|
1604
1943
|
return changed
|
|
@@ -1613,7 +1952,7 @@ def opencode_contract():
|
|
|
1613
1952
|
def contract_artifacts(hosts):
|
|
1614
1953
|
"""{path: (fresh render, writer)} for every always-on contract artifact
|
|
1615
1954
|
these hosts read: the managed block of each static rules file (Claude,
|
|
1616
|
-
Codex, omp) and opencode's contract and skill routers."""
|
|
1955
|
+
Codex, omp, Qwen, Droid) and opencode's contract and skill routers."""
|
|
1617
1956
|
out = {}
|
|
1618
1957
|
for h in hosts:
|
|
1619
1958
|
if h in STATIC_RULES:
|
|
@@ -1933,7 +2272,10 @@ def install_opencode():
|
|
|
1933
2272
|
{"$schema": "https://opencode.ai/config.json"})
|
|
1934
2273
|
if cfg is None:
|
|
1935
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
|
|
1936
2277
|
instr = cfg.get("instructions") or []
|
|
2278
|
+
wired = any(isinstance(i, str) and "tezgah" in i for i in instr)
|
|
1937
2279
|
for extra in (contract, SKILL_ROUTER):
|
|
1938
2280
|
if extra not in instr:
|
|
1939
2281
|
instr.append(extra)
|
|
@@ -1981,6 +2323,9 @@ def install_opencode():
|
|
|
1981
2323
|
# "[Old tool result content cleared]" so a long session stops re-sending
|
|
1982
2324
|
# every output on every step. setdefault keeps a user's explicit choice.
|
|
1983
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")
|
|
1984
2329
|
cfg.setdefault("compaction", {}).setdefault("prune", True)
|
|
1985
2330
|
wat = cfg.setdefault("watcher", {})
|
|
1986
2331
|
ign = wat.setdefault("ignore", [])
|
|
@@ -2052,6 +2397,12 @@ def install_cursor():
|
|
|
2052
2397
|
cc = os.path.join(CURSOR, "cli-config.json")
|
|
2053
2398
|
cfg = read_config(cc, {})
|
|
2054
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")
|
|
2055
2406
|
cfg.setdefault("version", 1)
|
|
2056
2407
|
cfg["statusLine"] = {
|
|
2057
2408
|
"type": "command",
|
|
@@ -2087,6 +2438,512 @@ def install_cursor():
|
|
|
2087
2438
|
restart_hint("cursor", mpath)
|
|
2088
2439
|
|
|
2089
2440
|
|
|
2441
|
+
def qwen_hook_groups():
|
|
2442
|
+
"""{event: the group install_qwen writes}; `hook_entries` plans from it."""
|
|
2443
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-qwen-hook"))
|
|
2444
|
+
out = {}
|
|
2445
|
+
for ev, matcher, timeout in QWEN_HOOK_EVENTS:
|
|
2446
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2447
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2448
|
+
out[ev] = group
|
|
2449
|
+
return out
|
|
2450
|
+
|
|
2451
|
+
|
|
2452
|
+
def install_qwen():
|
|
2453
|
+
"""Qwen Code: hook groups and the status line in `settings.json`, skill
|
|
2454
|
+
links, and the always-on block in `QWEN.md`. settings.json is shared - Orca
|
|
2455
|
+
writes its own hooks there and the user keeps provider keys in it - so only a
|
|
2456
|
+
group naming tezgah is replaced, the test uninstall drops by, and the backup
|
|
2457
|
+
`write_json` lays down is owner-only (`backup`)."""
|
|
2458
|
+
print("\nqwen:")
|
|
2459
|
+
path = os.path.join(QWEN, "settings.json")
|
|
2460
|
+
data = read_config(path, {})
|
|
2461
|
+
if data is not None:
|
|
2462
|
+
hooks = data.setdefault("hooks", {})
|
|
2463
|
+
for ev, group in qwen_hook_groups().items():
|
|
2464
|
+
arr = hooks.setdefault(ev, [])
|
|
2465
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2466
|
+
arr.append(group)
|
|
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")
|
|
2471
|
+
data.setdefault("ui", {})["statusLine"] = {
|
|
2472
|
+
"type": "command",
|
|
2473
|
+
"command": tp.hook_command(
|
|
2474
|
+
os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --qwen",
|
|
2475
|
+
"respectUserColors": True,
|
|
2476
|
+
}
|
|
2477
|
+
write_json(path, data)
|
|
2478
|
+
print("%s %s (hooks + ui.statusLine)" % (OK, path))
|
|
2479
|
+
for s in SKILLS:
|
|
2480
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
|
|
2481
|
+
write_rules("qwen")
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def droid_hook_groups():
|
|
2485
|
+
"""{event: the group install_droid writes}; `hook_entries` plans from it."""
|
|
2486
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-droid-hook"))
|
|
2487
|
+
out = {}
|
|
2488
|
+
for ev, matcher, timeout in DROID_HOOK_EVENTS:
|
|
2489
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2490
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2491
|
+
out[ev] = group
|
|
2492
|
+
return out
|
|
2493
|
+
|
|
2494
|
+
|
|
2495
|
+
# a JSON string, kept, or a `//` / `/* */` comment, dropped
|
|
2496
|
+
JSONC_TOKENS = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.S)
|
|
2497
|
+
|
|
2498
|
+
|
|
2499
|
+
def read_jsonc(path):
|
|
2500
|
+
"""The object in a JSON-with-comments file (Droid's settings.json), {} when
|
|
2501
|
+
the file is absent, None when it does not parse. A whole rewrite would drop
|
|
2502
|
+
the user's comments, so the only writers are `jsonc_put`/`jsonc_drop`.
|
|
2503
|
+
ponytail: a trailing comma inside a string value is dropped too; the readers
|
|
2504
|
+
only ask for top-level keys."""
|
|
2505
|
+
try:
|
|
2506
|
+
with open(path, encoding="utf-8") as fh:
|
|
2507
|
+
text = fh.read()
|
|
2508
|
+
except FileNotFoundError:
|
|
2509
|
+
return {}
|
|
2510
|
+
except (OSError, ValueError): # unreadable, or not UTF-8
|
|
2511
|
+
return None
|
|
2512
|
+
return jsonc_loads(text)
|
|
2513
|
+
|
|
2514
|
+
|
|
2515
|
+
def droid_hooks_blocked():
|
|
2516
|
+
"""Why tezgah must not create ~/.factory/hooks.json, or None. Droid reads the
|
|
2517
|
+
`hooks` key of settings.json only while hooks.json is absent, so creating one
|
|
2518
|
+
beside such a key would switch the user's own hooks off."""
|
|
2519
|
+
if os.path.exists(os.path.join(DROID, "hooks.json")):
|
|
2520
|
+
return None
|
|
2521
|
+
path = os.path.join(DROID, "settings.json")
|
|
2522
|
+
data = read_jsonc(path)
|
|
2523
|
+
if data is None:
|
|
2524
|
+
return "%s does not parse, so its `hooks` key cannot be ruled out" % path
|
|
2525
|
+
if "hooks" in data:
|
|
2526
|
+
return ("%s carries a `hooks` key, which Droid ignores once hooks.json "
|
|
2527
|
+
"exists - move those hooks into hooks.json, then re-run --install" % path)
|
|
2528
|
+
return None
|
|
2529
|
+
|
|
2530
|
+
|
|
2531
|
+
def droid_statusline():
|
|
2532
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --droid"
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def jsonc_loads(text):
|
|
2536
|
+
"""read_jsonc's parse of text already read: the object, or None."""
|
|
2537
|
+
text = re.sub(r",(\s*[}\]])", r"\1", JSONC_TOKENS.sub(lambda m: m.group(1) or "", text))
|
|
2538
|
+
try:
|
|
2539
|
+
data = json.loads(text)
|
|
2540
|
+
except ValueError:
|
|
2541
|
+
return None
|
|
2542
|
+
return data if isinstance(data, dict) else None
|
|
2543
|
+
|
|
2544
|
+
|
|
2545
|
+
# the object's opening brace, past any comment or string before it
|
|
2546
|
+
JSONC_OPEN = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/|(\{)', re.S)
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def jsonc_put(text, key, value):
|
|
2550
|
+
"""`text` (JSON with comments) with the top-level `key` set to `value` and
|
|
2551
|
+
every comment kept, or None when that cannot be done safely: the file does
|
|
2552
|
+
not parse, the key holds an object or list, or the edit would change
|
|
2553
|
+
anything else. An absent key goes in as one line under the opening brace,
|
|
2554
|
+
the shape `jsonc_drop` takes back out."""
|
|
2555
|
+
data = jsonc_loads(text)
|
|
2556
|
+
if data is None:
|
|
2557
|
+
return None
|
|
2558
|
+
want = dict(data, **{key: value})
|
|
2559
|
+
if key in data:
|
|
2560
|
+
if data[key] == value:
|
|
2561
|
+
return text
|
|
2562
|
+
if isinstance(data[key], (dict, list)):
|
|
2563
|
+
return None
|
|
2564
|
+
new = re.sub(r'("%s"\s*:\s*)%s' % (re.escape(key), re.escape(json.dumps(data[key]))),
|
|
2565
|
+
lambda m: m.group(1) + json.dumps(value), text, count=1)
|
|
2566
|
+
else:
|
|
2567
|
+
brace = next((m for m in JSONC_OPEN.finditer(text) if m.group(2)), None)
|
|
2568
|
+
if brace is None:
|
|
2569
|
+
return None
|
|
2570
|
+
line = "\n %s: %s%s" % (json.dumps(key), json.dumps(value), "," if data else "")
|
|
2571
|
+
new = text[:brace.end()] + line + text[brace.end():]
|
|
2572
|
+
return new if jsonc_loads(new) == want else None
|
|
2573
|
+
|
|
2574
|
+
|
|
2575
|
+
def jsonc_drop(text, key, value):
|
|
2576
|
+
"""`text` without the one-line `key: value` member `jsonc_put` inserted, or
|
|
2577
|
+
None when that exact line is not there or its removal changes anything else."""
|
|
2578
|
+
data = jsonc_loads(text)
|
|
2579
|
+
if data is None or data.get(key) != value:
|
|
2580
|
+
return None
|
|
2581
|
+
new = re.sub(r'\n[ \t]*%s: %s,?' % (re.escape(json.dumps(key)), re.escape(json.dumps(value))),
|
|
2582
|
+
"", text, count=1)
|
|
2583
|
+
# the line's own comma may now sit before the closing brace (comments may
|
|
2584
|
+
# follow it): take it out, unless the file already carried one there
|
|
2585
|
+
tail = r",(\s*}(?:\s|//[^\n]*|/\*.*?\*/)*)$"
|
|
2586
|
+
if not re.search(tail, text, re.S):
|
|
2587
|
+
new = re.sub(tail, r"\1", new, flags=re.S)
|
|
2588
|
+
rest = dict(data)
|
|
2589
|
+
rest.pop(key)
|
|
2590
|
+
return new if jsonc_loads(new) == rest else None
|
|
2591
|
+
|
|
2592
|
+
|
|
2593
|
+
def install_droid():
|
|
2594
|
+
"""Factory Droid: hook groups in `~/.factory/hooks.json`, the status line in
|
|
2595
|
+
`settings.json`, skill links, and the always-on block in `AGENTS.md`.
|
|
2596
|
+
hooks.json may hold the user's own groups, so only a group naming tezgah is
|
|
2597
|
+
replaced. It is not created while settings.json declares hooks
|
|
2598
|
+
(`droid_hooks_blocked`). settings.json is JSON with comments, so the status
|
|
2599
|
+
line and `includeCoAuthoredByDroid: false` (no co-author trailer: the
|
|
2600
|
+
no-attribution rule) are edited in place with every comment kept
|
|
2601
|
+
(`jsonc_put`); a file that cannot be edited safely is left alone and named."""
|
|
2602
|
+
print("\ndroid:")
|
|
2603
|
+
path = os.path.join(DROID, "hooks.json")
|
|
2604
|
+
why = droid_hooks_blocked()
|
|
2605
|
+
if why:
|
|
2606
|
+
print("%s %s not written: %s" % (NO, path, why))
|
|
2607
|
+
else:
|
|
2608
|
+
data = read_config(path, {})
|
|
2609
|
+
if data is not None:
|
|
2610
|
+
for ev, group in droid_hook_groups().items():
|
|
2611
|
+
arr = data.setdefault(ev, [])
|
|
2612
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2613
|
+
arr.append(group)
|
|
2614
|
+
write_json(path, data)
|
|
2615
|
+
print("%s %s (hooks)" % (OK, path))
|
|
2616
|
+
spath = os.path.join(DROID, "settings.json")
|
|
2617
|
+
try:
|
|
2618
|
+
with open(spath, encoding="utf-8") as fh:
|
|
2619
|
+
text = fh.read()
|
|
2620
|
+
except FileNotFoundError:
|
|
2621
|
+
text = "{}\n"
|
|
2622
|
+
except (OSError, ValueError): # unreadable, or not UTF-8
|
|
2623
|
+
text = None
|
|
2624
|
+
# the command's stdout is drawn above the input (settings reference)
|
|
2625
|
+
line = {"command": droid_statusline()}
|
|
2626
|
+
try:
|
|
2627
|
+
plain = json.loads(text) if text is not None else None
|
|
2628
|
+
except ValueError:
|
|
2629
|
+
plain = None # comments: edit in place
|
|
2630
|
+
if why:
|
|
2631
|
+
pass # the install stopped at the user's own `hooks` key: settings stays as it is
|
|
2632
|
+
elif isinstance(plain, dict):
|
|
2633
|
+
# plain JSON (an earlier install wrote it): set both keys and rewrite, as before
|
|
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")
|
|
2639
|
+
if ours or "statusLine" not in plain:
|
|
2640
|
+
plain["statusLine"] = line
|
|
2641
|
+
plain["includeCoAuthoredByDroid"] = False
|
|
2642
|
+
write_json(spath, plain)
|
|
2643
|
+
print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
|
|
2644
|
+
OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
|
|
2645
|
+
elif text is not None:
|
|
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")
|
|
2652
|
+
stale = old != line and "tezgah-statusline" in json.dumps(old or "")
|
|
2653
|
+
if stale:
|
|
2654
|
+
dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
|
|
2655
|
+
text, stale = (dropped, False) if dropped is not None else (text, True)
|
|
2656
|
+
# each key on its own: a user's own statusLine never blocks the trailer switch
|
|
2657
|
+
new = jsonc_put(text, "statusLine", line) or text
|
|
2658
|
+
new = jsonc_put(new, "includeCoAuthoredByDroid", False) or new
|
|
2659
|
+
if new != text:
|
|
2660
|
+
write_text(spath, new)
|
|
2661
|
+
done = jsonc_loads(new) or {}
|
|
2662
|
+
print("%s %s (statusLine%s, includeCoAuthoredByDroid %s)" % (
|
|
2663
|
+
OK if done.get("includeCoAuthoredByDroid") is False and not stale else NO, spath,
|
|
2664
|
+
"" if done.get("statusLine") == line else
|
|
2665
|
+
" from an earlier install could not be refreshed: set it with /statusline to `%s`"
|
|
2666
|
+
% line["command"] if stale else " left as the user set it",
|
|
2667
|
+
"false" if done.get("includeCoAuthoredByDroid") is False else "not set: edit by hand"))
|
|
2668
|
+
else:
|
|
2669
|
+
print("%s %s cannot be read: set the status line with /statusline to `%s` and "
|
|
2670
|
+
"`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
|
|
2671
|
+
for s in SKILLS:
|
|
2672
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
|
|
2673
|
+
write_rules("droid")
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
def commandcode_hook_groups():
|
|
2677
|
+
"""{event: the group install_commandcode writes}; `hook_entries` plans from it."""
|
|
2678
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-commandcode-hook"))
|
|
2679
|
+
out = {}
|
|
2680
|
+
for ev, matcher, timeout in COMMANDCODE_HOOK_EVENTS:
|
|
2681
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2682
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2683
|
+
out[ev] = group
|
|
2684
|
+
return out
|
|
2685
|
+
|
|
2686
|
+
|
|
2687
|
+
def install_commandcode():
|
|
2688
|
+
"""Command Code: hook groups in the `hooks` key of
|
|
2689
|
+
`~/.commandcode/settings.json`, skill links, and the always-on block in
|
|
2690
|
+
`AGENTS.md`. settings.json is the user's file, so only a group naming tezgah
|
|
2691
|
+
is replaced and the backup is owner-only (`backup`). The same write turns
|
|
2692
|
+
off the default `Co-authored-by: CommandCodeBot` commit trailer
|
|
2693
|
+
(`attribution.commit` "", docs/settings) unless the user set a value."""
|
|
2694
|
+
print("\ncommandcode:")
|
|
2695
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
2696
|
+
data = read_config(path, {})
|
|
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") == ""
|
|
2703
|
+
hooks = data.setdefault("hooks", {})
|
|
2704
|
+
for ev, group in commandcode_hook_groups().items():
|
|
2705
|
+
arr = hooks.setdefault(ev, [])
|
|
2706
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
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")
|
|
2711
|
+
attr = data.setdefault("attribution", {})
|
|
2712
|
+
if isinstance(attr, dict) and "commit" not in attr:
|
|
2713
|
+
attr["commit"] = ""
|
|
2714
|
+
write_json(path, data)
|
|
2715
|
+
print("%s %s (hooks + attribution.commit)" % (OK, path))
|
|
2716
|
+
if not isinstance(attr, dict) or attr.get("commit") != "":
|
|
2717
|
+
print("%s %s keeps your attribution.commit, so commits still carry a "
|
|
2718
|
+
"co-author trailer" % (NO, path))
|
|
2719
|
+
for s in SKILLS:
|
|
2720
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
|
|
2721
|
+
write_rules("commandcode")
|
|
2722
|
+
|
|
2723
|
+
|
|
2724
|
+
def copilot_hook_entries():
|
|
2725
|
+
"""{event: the entry install_copilot writes}; `hook_entries` plans from it."""
|
|
2726
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-copilot-hook"))
|
|
2727
|
+
out = {}
|
|
2728
|
+
for ev, matcher, timeout in COPILOT_HOOK_EVENTS:
|
|
2729
|
+
entry = {"type": "command", "timeoutSec": timeout,
|
|
2730
|
+
# a camelCase payload names no event, so the command does
|
|
2731
|
+
"command": cmd + " " + ev if ev[0].islower() else cmd}
|
|
2732
|
+
if matcher:
|
|
2733
|
+
entry["matcher"] = matcher
|
|
2734
|
+
out[ev] = entry
|
|
2735
|
+
return out
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def copilot_statusline():
|
|
2739
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --copilot"
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def install_copilot():
|
|
2743
|
+
"""GitHub Copilot CLI: `$COPILOT_HOME/hooks/tezgah.json`, a hooks file wholly
|
|
2744
|
+
tezgah's (the inline `hooks` key of settings.json is never used: one bad item
|
|
2745
|
+
there rejects every hook in it), the status line and `includeCoAuthoredBy`
|
|
2746
|
+
in settings.json, skill links, and the always-on block in
|
|
2747
|
+
`copilot-instructions.md`. settings.json is JSON with comments, so it is
|
|
2748
|
+
written only while it parses as plain JSON; `includeCoAuthoredBy` (default
|
|
2749
|
+
true, a `Co-authored-by` trailer on the agent's commits) is set false unless
|
|
2750
|
+
the user set it."""
|
|
2751
|
+
print("\ncopilot:")
|
|
2752
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
2753
|
+
write_json(path, {"version": 1, "hooks": {ev: [e] for ev, e in
|
|
2754
|
+
copilot_hook_entries().items()}})
|
|
2755
|
+
print("%s %s (hooks)" % (OK, path))
|
|
2756
|
+
spath = os.path.join(COPILOT, "settings.json")
|
|
2757
|
+
settings = read_config(spath, {}, comments=True)
|
|
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")
|
|
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")
|
|
2767
|
+
settings.setdefault("includeCoAuthoredBy", False)
|
|
2768
|
+
write_json(spath, settings)
|
|
2769
|
+
print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
|
|
2770
|
+
if settings["includeCoAuthoredBy"] is not False:
|
|
2771
|
+
print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
|
|
2772
|
+
"co-author trailer" % (NO, spath))
|
|
2773
|
+
else:
|
|
2774
|
+
print("%s set `statusLine` to `%s` and `includeCoAuthoredBy` to false in %s "
|
|
2775
|
+
"by hand" % (NO, copilot_statusline(), spath))
|
|
2776
|
+
for s in SKILLS:
|
|
2777
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
|
|
2778
|
+
write_rules("copilot")
|
|
2779
|
+
|
|
2780
|
+
|
|
2781
|
+
def gemini_hook_groups():
|
|
2782
|
+
"""{event: the group install_gemini writes}; `hook_entries` plans from it."""
|
|
2783
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-gemini-hook"))
|
|
2784
|
+
out = {}
|
|
2785
|
+
for ev, matcher, timeout in GEMINI_HOOK_EVENTS:
|
|
2786
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2787
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2788
|
+
out[ev] = group
|
|
2789
|
+
return out
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
def install_gemini():
|
|
2793
|
+
"""Gemini CLI: tezgah's own extension dir, `gemini-extension.json` beside
|
|
2794
|
+
`hooks/hooks.json` and `skills/` links, wholly tezgah's; and the always-on
|
|
2795
|
+
block in `GEMINI.md`. settings.json is never written: it is JSON with
|
|
2796
|
+
comments and holds the user's keys. Gemini's default prompt adds no
|
|
2797
|
+
co-author trailer and no setting names one, so there is nothing to turn off."""
|
|
2798
|
+
print("\ngemini:")
|
|
2799
|
+
write_json(os.path.join(GEMINI_EXT, "gemini-extension.json"),
|
|
2800
|
+
{"name": "tezgah", "version": plugin_version(),
|
|
2801
|
+
"description": "tezgah lifecycle hooks and skills"})
|
|
2802
|
+
path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
|
|
2803
|
+
write_json(path, {"hooks": {ev: [g] for ev, g in gemini_hook_groups().items()}})
|
|
2804
|
+
print("%s %s (extension hooks)" % (OK, path))
|
|
2805
|
+
for s in SKILLS:
|
|
2806
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
|
|
2807
|
+
write_rules("gemini")
|
|
2808
|
+
|
|
2809
|
+
|
|
2810
|
+
def antigravity_hook_groups():
|
|
2811
|
+
"""{event: the item install_antigravity writes under hooks.json's `tezgah`
|
|
2812
|
+
name}: a matcher group for a tool event, a bare handler for the others
|
|
2813
|
+
(docs/hooks "Schema and file format"); `hook_entries` plans from it."""
|
|
2814
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-antigravity-hook"))
|
|
2815
|
+
out = {}
|
|
2816
|
+
for ev, matcher, timeout in ANTIGRAVITY_HOOK_EVENTS:
|
|
2817
|
+
handler = {"type": "command", "command": cmd + " " + ev, "timeout": timeout}
|
|
2818
|
+
out[ev] = handler if matcher is None else {"matcher": matcher, "hooks": [handler]}
|
|
2819
|
+
return out
|
|
2820
|
+
|
|
2821
|
+
|
|
2822
|
+
def antigravity_statusline():
|
|
2823
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --antigravity"
|
|
2824
|
+
|
|
2825
|
+
|
|
2826
|
+
def install_antigravity():
|
|
2827
|
+
"""Antigravity CLI: tezgah's hook set under the top-level `tezgah` name of
|
|
2828
|
+
`~/.gemini/config/hooks.json`, beside the user's other names (hooks in
|
|
2829
|
+
antigravity-cli/settings.json are never loaded, docs/hooks); the Antigravity
|
|
2830
|
+
App and IDE read that file too. The status line in
|
|
2831
|
+
`~/.gemini/antigravity-cli/settings.json`, written only while it is plain
|
|
2832
|
+
JSON; skill links in `antigravity-cli/skills`; the always-on block in the
|
|
2833
|
+
`~/.gemini/GEMINI.md` Gemini CLI shares. Antigravity adds no co-author
|
|
2834
|
+
trailer of its own and no setting names one, so there is nothing to turn off."""
|
|
2835
|
+
print("\nantigravity:")
|
|
2836
|
+
data = read_config(AGY_HOOKS, {})
|
|
2837
|
+
if data is not None:
|
|
2838
|
+
data["tezgah"] = {ev: [g] for ev, g in antigravity_hook_groups().items()}
|
|
2839
|
+
write_json(AGY_HOOKS, data)
|
|
2840
|
+
print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
|
|
2841
|
+
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
2842
|
+
settings = read_config(spath, {}, comments=True)
|
|
2843
|
+
if isinstance(settings, dict):
|
|
2844
|
+
if "tezgah-statusline" not in json.dumps(settings.get("statusLine") or {}):
|
|
2845
|
+
remember_prior(spath, settings, "statusLine")
|
|
2846
|
+
settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
|
|
2847
|
+
write_json(spath, settings)
|
|
2848
|
+
print("%s %s (statusLine)" % (OK, spath))
|
|
2849
|
+
else:
|
|
2850
|
+
print("%s set `statusLine` to {\"type\": \"command\", \"command\": \"%s\"} in "
|
|
2851
|
+
"%s by hand" % (NO, antigravity_statusline(), spath))
|
|
2852
|
+
for s in SKILLS:
|
|
2853
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
|
|
2854
|
+
write_rules("antigravity")
|
|
2855
|
+
|
|
2856
|
+
|
|
2857
|
+
def goose_hook_groups():
|
|
2858
|
+
"""{event: the group install_goose writes}; `hook_entries` plans from it."""
|
|
2859
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-goose-hook"))
|
|
2860
|
+
out = {}
|
|
2861
|
+
for ev, matcher, timeout in GOOSE_HOOK_EVENTS:
|
|
2862
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2863
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2864
|
+
out[ev] = group
|
|
2865
|
+
return out
|
|
2866
|
+
|
|
2867
|
+
|
|
2868
|
+
def install_goose():
|
|
2869
|
+
"""Goose: tezgah's own plugin dir, `~/.agents/plugins/tezgah/`, wholly
|
|
2870
|
+
tezgah's - `plugin.json` beside `hooks/hooks.json` and `skills/` links (Goose
|
|
2871
|
+
loads them as `tezgah:<skill>`) - and the always-on block in
|
|
2872
|
+
`~/.config/goose/.goosehints`. The user's other `~/.agents` content is never
|
|
2873
|
+
touched. `on_failure` stays at its default `allow`, so a hook fault lets the
|
|
2874
|
+
call through. Neither Goose's docs nor its system prompt add a co-author
|
|
2875
|
+
trailer or name a setting for one, so there is nothing to turn off."""
|
|
2876
|
+
print("\ngoose:")
|
|
2877
|
+
write_json(os.path.join(GOOSE_PLUGIN, "plugin.json"),
|
|
2878
|
+
{"name": "tezgah", "version": plugin_version(),
|
|
2879
|
+
"description": "tezgah lifecycle hooks and skills"})
|
|
2880
|
+
path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
|
|
2881
|
+
write_json(path, {"hooks": {ev: [g] for ev, g in goose_hook_groups().items()}})
|
|
2882
|
+
print("%s %s (plugin hooks)" % (OK, path))
|
|
2883
|
+
for s in SKILLS:
|
|
2884
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
|
|
2885
|
+
write_rules("goose")
|
|
2886
|
+
|
|
2887
|
+
|
|
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."""
|
|
2895
|
+
path = os.path.join(AMP, "settings.json")
|
|
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)
|
|
2907
|
+
|
|
2908
|
+
|
|
2909
|
+
def install_amp():
|
|
2910
|
+
"""Amp: one system plugin file, `$XDG_CONFIG_HOME/amp/plugins/tezgah.ts`,
|
|
2911
|
+
rendered from `hosts/amp/tezgah-plugin.ts.in` (Amp loads every .ts there,
|
|
2912
|
+
nothing registers it); skill links in `~/.config/amp/skills`; the always-on
|
|
2913
|
+
block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
|
|
2914
|
+
in settings.json unless the user set them."""
|
|
2915
|
+
print("\namp:")
|
|
2916
|
+
wired = amp_plugin_ours() # an earlier install's plugin, before this one writes
|
|
2917
|
+
plugin = ta.amp_plugin(HERE)
|
|
2918
|
+
if not plugin:
|
|
2919
|
+
print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
|
|
2920
|
+
% (NO, AMP_PLUGIN))
|
|
2921
|
+
elif write_if_changed(AMP_PLUGIN, plugin):
|
|
2922
|
+
print("%s %s (plugin: gate + ledger + Stop rule)" % (OK, AMP_PLUGIN))
|
|
2923
|
+
print("%s amp: run `plugins: reload` in a running Amp to load it" % OK)
|
|
2924
|
+
spath, settings = amp_settings()
|
|
2925
|
+
if isinstance(settings, dict):
|
|
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)
|
|
2931
|
+
settings.setdefault(key, False)
|
|
2932
|
+
write_json(spath, settings)
|
|
2933
|
+
print("%s %s (commit trailers off)" % (OK, spath))
|
|
2934
|
+
for key in AMP_ATTRIBUTION:
|
|
2935
|
+
if settings[key] is not False:
|
|
2936
|
+
print("%s %s keeps your %s, so the agent's commits still carry "
|
|
2937
|
+
"that trailer" % (NO, spath, key))
|
|
2938
|
+
else:
|
|
2939
|
+
print("%s set %s to false in %s by hand"
|
|
2940
|
+
% (NO, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION), spath))
|
|
2941
|
+
for s in SKILLS:
|
|
2942
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
|
|
2943
|
+
write_rules("amp")
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
|
|
2090
2947
|
def dsh_app_mcp_rows():
|
|
2091
2948
|
"""One dsh-mcp-client row per MCP server tezgah writes into the block.
|
|
2092
2949
|
|
|
@@ -2330,12 +3187,17 @@ def write_if_changed(path, text):
|
|
|
2330
3187
|
def set_managed_md(path, block, start="<!-- tezgah:start -->",
|
|
2331
3188
|
end="<!-- tezgah:end -->"):
|
|
2332
3189
|
"""Replace the marked block in a markdown file, or append it, keeping any
|
|
2333
|
-
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."""
|
|
2334
3193
|
try:
|
|
2335
3194
|
with open(path, encoding="utf-8") as fh:
|
|
2336
3195
|
lines = fh.read().splitlines()
|
|
2337
|
-
except
|
|
3196
|
+
except FileNotFoundError:
|
|
2338
3197
|
lines = []
|
|
3198
|
+
except (OSError, ValueError) as exc:
|
|
3199
|
+
refuse(path, read_failure(exc))
|
|
3200
|
+
return False
|
|
2339
3201
|
out, skip = [], False
|
|
2340
3202
|
for ln in lines:
|
|
2341
3203
|
if ln.strip().startswith(start):
|
|
@@ -2432,7 +3294,10 @@ def strip_managed_md(path):
|
|
|
2432
3294
|
try:
|
|
2433
3295
|
with open(path, encoding="utf-8") as fh:
|
|
2434
3296
|
text = fh.read()
|
|
2435
|
-
except
|
|
3297
|
+
except FileNotFoundError:
|
|
3298
|
+
return
|
|
3299
|
+
except (OSError, ValueError) as exc:
|
|
3300
|
+
refuse(path, read_failure(exc), "--uninstall")
|
|
2436
3301
|
return
|
|
2437
3302
|
kept = re.sub(r"<!-- tezgah:start -->.*?<!-- tezgah:end -->\n?",
|
|
2438
3303
|
"", text, flags=re.S).strip()
|
|
@@ -2471,7 +3336,32 @@ CLAUDE_RULES = os.path.join(CLAUDE, "CLAUDE.md")
|
|
|
2471
3336
|
# and where that file is. A lambda per row, because codex_rules() answers from
|
|
2472
3337
|
# the disk (AGENTS.override.md wins when it exists).
|
|
2473
3338
|
STATIC_RULES = {"claude": lambda: CLAUDE_RULES, "codex": codex_rules,
|
|
2474
|
-
"omp": lambda: os.path.join(OMP_AGENT, "RULES.md")
|
|
3339
|
+
"omp": lambda: os.path.join(OMP_AGENT, "RULES.md"),
|
|
3340
|
+
"qwen": lambda: os.path.join(QWEN, "QWEN.md"),
|
|
3341
|
+
"droid": lambda: os.path.join(DROID, "AGENTS.md"),
|
|
3342
|
+
"commandcode": lambda: os.path.join(COMMANDCODE, "AGENTS.md"),
|
|
3343
|
+
"copilot": lambda: os.path.join(COPILOT, "copilot-instructions.md"),
|
|
3344
|
+
"gemini": lambda: os.path.join(GEMINI, "GEMINI.md"),
|
|
3345
|
+
"antigravity": lambda: os.path.join(os.path.dirname(ANTIGRAVITY), "GEMINI.md"),
|
|
3346
|
+
"goose": lambda: os.path.join(GOOSE, ".goosehints"),
|
|
3347
|
+
"amp": lambda: os.path.join(AMP, "AGENTS.md")}
|
|
3348
|
+
# Gemini CLI and Antigravity both load ~/.gemini/GEMINI.md, so they share ONE
|
|
3349
|
+
# managed block: an uninstall strips it only while no other host of this tuple
|
|
3350
|
+
# that reads the same file stays armed (`gemini_md_kept`; GEMINI_CLI_HOME moves
|
|
3351
|
+
# Gemini's copy away), and each host's uninstall asks the same question, so the
|
|
3352
|
+
# last one out takes the block.
|
|
3353
|
+
GEMINI_MD_HOSTS = ("gemini", "antigravity")
|
|
3354
|
+
# the hosts the running uninstall takes out (`uninstall` sets it)
|
|
3355
|
+
UNINSTALLING = ()
|
|
3356
|
+
|
|
3357
|
+
|
|
3358
|
+
def gemini_md_kept(host):
|
|
3359
|
+
"""True while another GEMINI_MD_HOSTS host this uninstall does not touch is
|
|
3360
|
+
armed and reads `host`'s GEMINI.md."""
|
|
3361
|
+
armed = tp.config().get("hosts") or []
|
|
3362
|
+
path = STATIC_RULES[host]()
|
|
3363
|
+
return any(h in armed and h not in UNINSTALLING and STATIC_RULES[h]() == path
|
|
3364
|
+
for h in GEMINI_MD_HOSTS)
|
|
2475
3365
|
|
|
2476
3366
|
|
|
2477
3367
|
def rules_block(host):
|
|
@@ -2481,10 +3371,11 @@ def rules_block(host):
|
|
|
2481
3371
|
|
|
2482
3372
|
|
|
2483
3373
|
def write_rules(host):
|
|
2484
|
-
"""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`."""
|
|
2485
3376
|
path = STATIC_RULES[host]()
|
|
2486
|
-
write_artifact(path, rules_block(host), set_managed_md)
|
|
2487
|
-
|
|
3377
|
+
if write_artifact(path, rules_block(host), set_managed_md) is not None:
|
|
3378
|
+
print("%s %s (always-on contract)" % (OK, path))
|
|
2488
3379
|
|
|
2489
3380
|
|
|
2490
3381
|
def rules_current(host):
|
|
@@ -2496,7 +3387,7 @@ def rules_current(host):
|
|
|
2496
3387
|
try:
|
|
2497
3388
|
with open(STATIC_RULES[host](), encoding="utf-8") as fh:
|
|
2498
3389
|
lines = fh.read().splitlines()
|
|
2499
|
-
except OSError:
|
|
3390
|
+
except (OSError, ValueError): # unreadable, or not UTF-8: not current
|
|
2500
3391
|
return False
|
|
2501
3392
|
starts = [i for i, ln in enumerate(lines)
|
|
2502
3393
|
if ln.strip().startswith("<!-- tezgah:start -->")]
|
|
@@ -2517,7 +3408,7 @@ def omp_bridge():
|
|
|
2517
3408
|
def install_omp():
|
|
2518
3409
|
print("\nomp:")
|
|
2519
3410
|
mkdirp(OMP_AGENT)
|
|
2520
|
-
|
|
3411
|
+
write_rules("omp")
|
|
2521
3412
|
for s in SKILLS:
|
|
2522
3413
|
print(link(os.path.join(HERE, "skills", s),
|
|
2523
3414
|
os.path.join(OMP_AGENT, "skills", s)))
|
|
@@ -2586,7 +3477,11 @@ def install_omp():
|
|
|
2586
3477
|
|
|
2587
3478
|
INSTALLERS = {"claude": install_claude, "codex": install_codex,
|
|
2588
3479
|
"opencode": install_opencode, "cursor": install_cursor,
|
|
2589
|
-
"dsh": install_dsh, "omp": install_omp
|
|
3480
|
+
"dsh": install_dsh, "omp": install_omp, "qwen": install_qwen,
|
|
3481
|
+
"droid": install_droid, "commandcode": install_commandcode,
|
|
3482
|
+
"copilot": install_copilot, "gemini": install_gemini,
|
|
3483
|
+
"antigravity": install_antigravity, "goose": install_goose,
|
|
3484
|
+
"amp": install_amp}
|
|
2590
3485
|
|
|
2591
3486
|
|
|
2592
3487
|
def install_openresearch(hosts):
|
|
@@ -3041,9 +3936,11 @@ def is_tezgah_link(path):
|
|
|
3041
3936
|
wiring the user's."""
|
|
3042
3937
|
if not os.path.islink(path):
|
|
3043
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
|
|
3044
3941
|
target = os.path.realpath(path)
|
|
3045
3942
|
return any(target == root or target.startswith(root + os.sep)
|
|
3046
|
-
for root in (os.path.realpath(HERE
|
|
3943
|
+
for root in map(os.path.realpath, (HERE, INSTALL_PREFIX, tp.BIN_DIR)))
|
|
3047
3944
|
|
|
3048
3945
|
|
|
3049
3946
|
def is_tezgah_entry(path):
|
|
@@ -3083,6 +3980,9 @@ def unlink_dir(d):
|
|
|
3083
3980
|
def drop_managed_hook_entries(path):
|
|
3084
3981
|
"""Remove only tezgah's own entries from a host hooks.json."""
|
|
3085
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
|
|
3086
3986
|
if not data:
|
|
3087
3987
|
print("%s no %s" % (OK, path))
|
|
3088
3988
|
return
|
|
@@ -3109,9 +4009,12 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3109
4009
|
try:
|
|
3110
4010
|
with open(path, encoding="utf-8") as fh:
|
|
3111
4011
|
lines = fh.read().splitlines()
|
|
3112
|
-
except
|
|
4012
|
+
except FileNotFoundError:
|
|
3113
4013
|
print("%s no %s" % (OK, path))
|
|
3114
4014
|
return
|
|
4015
|
+
except (OSError, ValueError) as exc:
|
|
4016
|
+
refuse(path, read_failure(exc), "--uninstall")
|
|
4017
|
+
return
|
|
3115
4018
|
out, skip, changed = [], False, False
|
|
3116
4019
|
for ln in lines:
|
|
3117
4020
|
stripped = ln.strip()
|
|
@@ -3134,8 +4037,17 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3134
4037
|
return
|
|
3135
4038
|
while out and not out[-1].strip():
|
|
3136
4039
|
out.pop()
|
|
3137
|
-
# A dsh patch layer
|
|
3138
|
-
#
|
|
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)
|
|
3139
4051
|
if not any(ln.strip() and not ln.strip().startswith("#") for ln in out):
|
|
3140
4052
|
out.append("[]")
|
|
3141
4053
|
with open(path, "w", encoding="utf-8") as fh:
|
|
@@ -3144,8 +4056,6 @@ def remove_managed_block(path, start="# tezgah:start", end="# tezgah:end"):
|
|
|
3144
4056
|
|
|
3145
4057
|
|
|
3146
4058
|
def uninstall_common():
|
|
3147
|
-
print("\ncommon:")
|
|
3148
|
-
unlink_dir(tp.BIN_DIR)
|
|
3149
4059
|
removed = tezgah_agents.cleanup()
|
|
3150
4060
|
if removed:
|
|
3151
4061
|
print("%s removed %d generated repo agent file(s)" % (OK, removed))
|
|
@@ -3255,20 +4165,21 @@ def _drop_state_entry(path):
|
|
|
3255
4165
|
|
|
3256
4166
|
|
|
3257
4167
|
def remove_generated_state(full):
|
|
3258
|
-
"""Take tezgah's state back
|
|
3259
|
-
|
|
4168
|
+
"""Take tezgah's state back when the uninstall is full: the cache dirs and
|
|
4169
|
+
the config dir's generated contents.
|
|
3260
4170
|
|
|
3261
4171
|
`full` means the run covers every host config.json records - the armed set.
|
|
3262
|
-
A partial uninstall keeps
|
|
3263
|
-
|
|
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
|
|
3264
4175
|
wiring this run did not touch. `adopted/` and any entry that is not one of
|
|
3265
4176
|
tezgah's generated names survive in both cases."""
|
|
4177
|
+
if not full:
|
|
4178
|
+
return
|
|
3266
4179
|
for d in (tp.CACHE, tp.fallback_cache()):
|
|
3267
4180
|
if os.path.isdir(d):
|
|
3268
4181
|
shutil.rmtree(d, ignore_errors=True)
|
|
3269
4182
|
print("%s removed %s" % (OK, d))
|
|
3270
|
-
if not full:
|
|
3271
|
-
return
|
|
3272
4183
|
try:
|
|
3273
4184
|
names = sorted(os.listdir(tp.CONFIG_DIR))
|
|
3274
4185
|
except OSError as exc:
|
|
@@ -3354,14 +4265,22 @@ def remove_install_tree():
|
|
|
3354
4265
|
print("%s kept %s (holds %s)" % (NO, INSTALL_PREFIX, ", ".join(sorted(rest))))
|
|
3355
4266
|
|
|
3356
4267
|
|
|
3357
|
-
def
|
|
3358
|
-
"""
|
|
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."""
|
|
3359
4277
|
try:
|
|
3360
4278
|
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
3361
4279
|
body = fh.read()
|
|
3362
4280
|
except OSError:
|
|
3363
4281
|
return True, ""
|
|
3364
|
-
|
|
4282
|
+
needles = (needle,) if isinstance(needle, str) else needle
|
|
4283
|
+
return not any(n in body for n in needles), path
|
|
3365
4284
|
|
|
3366
4285
|
|
|
3367
4286
|
def verify_uninstall(hosts, full):
|
|
@@ -3381,7 +4300,21 @@ def verify_uninstall(hosts, full):
|
|
|
3381
4300
|
else:
|
|
3382
4301
|
print("%s %s" % (OK, label))
|
|
3383
4302
|
|
|
3384
|
-
|
|
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):
|
|
3385
4318
|
left = []
|
|
3386
4319
|
for p in paths:
|
|
3387
4320
|
ok, leftover = _text_clean(p, needle)
|
|
@@ -3393,13 +4326,17 @@ def verify_uninstall(hosts, full):
|
|
|
3393
4326
|
else:
|
|
3394
4327
|
print("%s %s" % (OK, label))
|
|
3395
4328
|
|
|
3396
|
-
# 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
|
|
3397
4331
|
farm = [os.path.join(tp.BIN_DIR, n)
|
|
3398
4332
|
for n in (os.listdir(tp.BIN_DIR) if os.path.isdir(tp.BIN_DIR) else [])]
|
|
3399
|
-
|
|
3400
|
-
|
|
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)])
|
|
3401
4338
|
claim("materialised ledger gone", [MATERIALISED] if full else [])
|
|
3402
|
-
claim("state caches gone", [tp.CACHE, tp.fallback_cache()])
|
|
4339
|
+
claim("state caches gone", [tp.CACHE, tp.fallback_cache()] if full else [])
|
|
3403
4340
|
if full:
|
|
3404
4341
|
try:
|
|
3405
4342
|
has_tree = os.path.isdir(INSTALL_PREFIX) and any(
|
|
@@ -3423,11 +4360,11 @@ def verify_uninstall(hosts, full):
|
|
|
3423
4360
|
for n in user_agent_files("claude")])
|
|
3424
4361
|
if "codex" in hosts:
|
|
3425
4362
|
clean("codex hooks.json unwired", [os.path.join(CODEX, "hooks.json")])
|
|
3426
|
-
claim("codex skills unlinked",
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
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 [])
|
|
3431
4368
|
# the user's own AGENTS.md may name tezgah; only the block is ours
|
|
3432
4369
|
clean("codex global AGENTS.md block gone",
|
|
3433
4370
|
[os.path.join(CODEX, n) for n in CODEX_RULES],
|
|
@@ -3441,20 +4378,24 @@ def verify_uninstall(hosts, full):
|
|
|
3441
4378
|
+ [os.path.join(OPENCODE, "tui-plugins", n)
|
|
3442
4379
|
for n in ("tezgah-tui.tsx", "tezgah-cli")])
|
|
3443
4380
|
clean("opencode opencode.json unwired",
|
|
3444
|
-
[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)
|
|
3445
4383
|
clean("opencode cli.json unwired", [os.path.join(OPENCODE, "cli.json")],
|
|
3446
4384
|
needle=OPENCODE_CLI_PLUGIN)
|
|
3447
4385
|
claim("opencode generated routers gone", [SKILL_ROUTER, SKILL_ROUTER_FULL])
|
|
3448
4386
|
if "cursor" in hosts:
|
|
3449
4387
|
clean("cursor hooks.json unwired", [os.path.join(CURSOR, "hooks.json")])
|
|
3450
|
-
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)
|
|
3451
4390
|
clean("cursor cli-config.json unwired",
|
|
3452
4391
|
[os.path.join(CURSOR, "cli-config.json")])
|
|
3453
4392
|
if "dsh" in hosts:
|
|
3454
|
-
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")
|
|
3455
4395
|
claim("dsh launcher gone", [dsh_launcher()[1]])
|
|
3456
4396
|
if "omp" in hosts:
|
|
3457
|
-
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")
|
|
3458
4399
|
claim("omp hook script gone",
|
|
3459
4400
|
[os.path.join(OMP_AGENT, "hooks", "pre", "tezgah-hook.ts")])
|
|
3460
4401
|
agents = os.path.join(OMP_AGENT, "agents")
|
|
@@ -3462,13 +4403,61 @@ def verify_uninstall(hosts, full):
|
|
|
3462
4403
|
[os.path.join(agents, n) for n in
|
|
3463
4404
|
(os.listdir(agents) if os.path.isdir(agents) else [])
|
|
3464
4405
|
if n[:-len(".md")] in tezgah_agents.OWNED_NAMES and n.endswith(".md")])
|
|
3465
|
-
clean("omp mcp.json unwired", [os.path.join(OMP_AGENT, "mcp.json")]
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
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))
|
|
4409
|
+
if "qwen" in hosts:
|
|
4410
|
+
clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
|
|
4411
|
+
clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
|
|
4412
|
+
needle="<!-- tezgah:start")
|
|
4413
|
+
claim("qwen skills unlinked", skills(QWEN))
|
|
4414
|
+
if "droid" in hosts:
|
|
4415
|
+
clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
|
|
4416
|
+
clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
|
|
4417
|
+
needle="tezgah-statusline")
|
|
4418
|
+
clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
|
|
4419
|
+
needle="<!-- tezgah:start")
|
|
4420
|
+
claim("droid skills unlinked", skills(DROID))
|
|
4421
|
+
if "commandcode" in hosts:
|
|
4422
|
+
clean("commandcode settings.json unwired",
|
|
4423
|
+
[os.path.join(COMMANDCODE, "settings.json")])
|
|
4424
|
+
clean("commandcode global AGENTS.md block gone",
|
|
4425
|
+
[os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
|
|
4426
|
+
claim("commandcode skills unlinked", skills(COMMANDCODE))
|
|
4427
|
+
if "copilot" in hosts:
|
|
4428
|
+
claim("copilot hooks/tezgah.json gone",
|
|
4429
|
+
[os.path.join(COPILOT, "hooks", "tezgah.json")])
|
|
4430
|
+
clean("copilot settings.json statusLine gone",
|
|
4431
|
+
[os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
|
|
4432
|
+
clean("copilot copilot-instructions.md block gone",
|
|
4433
|
+
[os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
|
|
4434
|
+
claim("copilot skills unlinked", skills(COPILOT))
|
|
4435
|
+
if "gemini" in hosts:
|
|
4436
|
+
claim("gemini extension dir gone", [GEMINI_EXT])
|
|
4437
|
+
if not gemini_md_kept("gemini"):
|
|
4438
|
+
clean("gemini GEMINI.md block gone", [os.path.join(GEMINI, "GEMINI.md")],
|
|
4439
|
+
needle="<!-- tezgah:start")
|
|
4440
|
+
if "antigravity" in hosts:
|
|
4441
|
+
clean("antigravity config/hooks.json unwired", [AGY_HOOKS],
|
|
4442
|
+
needle="tezgah-antigravity-hook")
|
|
4443
|
+
clean("antigravity settings.json statusLine gone",
|
|
4444
|
+
[os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
|
|
4445
|
+
claim("antigravity skills unlinked", skills(ANTIGRAVITY))
|
|
4446
|
+
if not gemini_md_kept("antigravity"):
|
|
4447
|
+
clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
|
|
4448
|
+
needle="<!-- tezgah:start")
|
|
4449
|
+
if "goose" in hosts:
|
|
4450
|
+
claim("goose plugin dir gone", [GOOSE_PLUGIN])
|
|
4451
|
+
clean("goose .goosehints block gone", [STATIC_RULES["goose"]()],
|
|
4452
|
+
needle="<!-- tezgah:start")
|
|
4453
|
+
if "amp" in hosts:
|
|
4454
|
+
claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
|
|
4455
|
+
claim("amp skills unlinked", skills(AMP))
|
|
4456
|
+
clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
|
|
4457
|
+
needle="<!-- tezgah:start")
|
|
3469
4458
|
if "claude" in hosts:
|
|
3470
4459
|
clean("claude settings.json unwired",
|
|
3471
|
-
[os.path.join(CLAUDE, "settings.json")])
|
|
4460
|
+
[os.path.join(CLAUDE, "settings.json")], needle=farm_mark + ('"tezgah@',))
|
|
3472
4461
|
claim("legacy kill switches gone",
|
|
3473
4462
|
[os.path.join(HOME, ".claude", n) for n in OFF_SWITCH_NAMES
|
|
3474
4463
|
if os.path.exists(os.path.join(HOME, ".claude", n))])
|
|
@@ -3482,16 +4471,16 @@ def verify_uninstall(hosts, full):
|
|
|
3482
4471
|
|
|
3483
4472
|
|
|
3484
4473
|
def unwire_claude_settings():
|
|
3485
|
-
"""
|
|
3486
|
-
|
|
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."""
|
|
3487
4477
|
sp = os.path.join(CLAUDE, "settings.json")
|
|
3488
4478
|
s = read_json(sp, None)
|
|
3489
|
-
if not s:
|
|
4479
|
+
if not s or not isinstance(s, dict):
|
|
3490
4480
|
return
|
|
3491
4481
|
changed = False
|
|
3492
4482
|
if "statusline.py" in json.dumps(s.get("statusLine") or {}):
|
|
3493
|
-
s
|
|
3494
|
-
changed = True
|
|
4483
|
+
changed = restore_prior(sp, s, "statusLine")
|
|
3495
4484
|
if s.get("attribution") == {"commit": "", "pr": "", "sessionUrl": False}:
|
|
3496
4485
|
s.pop("attribution", None)
|
|
3497
4486
|
changed = True
|
|
@@ -3500,9 +4489,12 @@ def unwire_claude_settings():
|
|
|
3500
4489
|
if isinstance(enabled, dict) else []:
|
|
3501
4490
|
del enabled[key]
|
|
3502
4491
|
changed = True
|
|
4492
|
+
if not enabled:
|
|
4493
|
+
s.pop("enabledPlugins")
|
|
3503
4494
|
if changed:
|
|
3504
4495
|
write_json(sp, s)
|
|
3505
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)
|
|
3506
4498
|
|
|
3507
4499
|
|
|
3508
4500
|
def unwire_opencode_config():
|
|
@@ -3541,8 +4533,9 @@ def unwire_opencode_config():
|
|
|
3541
4533
|
changed = True
|
|
3542
4534
|
comp = c.get("compaction")
|
|
3543
4535
|
if isinstance(comp, dict) and comp.get("prune") is True:
|
|
3544
|
-
|
|
3545
|
-
|
|
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
|
|
3546
4539
|
wat = c.get("watcher")
|
|
3547
4540
|
if isinstance(wat, dict) and isinstance(wat.get("ignore"), list):
|
|
3548
4541
|
kept = [p for p in wat["ignore"] if p not in OPENCODE_IGNORE]
|
|
@@ -3565,6 +4558,7 @@ def unwire_opencode_config():
|
|
|
3565
4558
|
if changed:
|
|
3566
4559
|
write_json(p, c)
|
|
3567
4560
|
print("%s removed tezgah instructions/skill-deny from %s" % (OK, p))
|
|
4561
|
+
forget_prior(p)
|
|
3568
4562
|
t = os.path.join(OPENCODE, "tui.json")
|
|
3569
4563
|
tj = read_json(t, None)
|
|
3570
4564
|
if tj and tj.get("plugin"):
|
|
@@ -3577,6 +4571,7 @@ def unwire_opencode_config():
|
|
|
3577
4571
|
tj.pop("plugin", None)
|
|
3578
4572
|
write_json(t, tj)
|
|
3579
4573
|
print("%s removed tezgah TUI plugin from %s" % (OK, t))
|
|
4574
|
+
drop_if_empty(t, {"$schema": "https://opencode.ai/tui.json"})
|
|
3580
4575
|
cp = os.path.join(OPENCODE, "cli.json")
|
|
3581
4576
|
cj = read_json(cp, None)
|
|
3582
4577
|
if cj and isinstance(cj.get("plugins"), list):
|
|
@@ -3585,6 +4580,7 @@ def unwire_opencode_config():
|
|
|
3585
4580
|
cj["plugins"] = kept
|
|
3586
4581
|
write_json(cp, cj)
|
|
3587
4582
|
print("%s removed tezgah CLI plugin from %s" % (OK, cp))
|
|
4583
|
+
forget_prior(cp)
|
|
3588
4584
|
|
|
3589
4585
|
|
|
3590
4586
|
def uninstall_claude():
|
|
@@ -3631,6 +4627,7 @@ def uninstall_codex():
|
|
|
3631
4627
|
for s in SKILLS:
|
|
3632
4628
|
unlink_tezgah(os.path.join(CODEX, "skills", s))
|
|
3633
4629
|
drop_managed_hook_entries(os.path.join(CODEX, "hooks.json"))
|
|
4630
|
+
drop_if_empty(os.path.join(CODEX, "hooks.json"))
|
|
3634
4631
|
strip_toml_mcp(os.path.join(CODEX, "config.toml"),
|
|
3635
4632
|
app_mcp_names() + [TEZGAH_MCP_NAME])
|
|
3636
4633
|
unlink_tezgah(os.path.join(CODEX, "bin", "consult"))
|
|
@@ -3662,6 +4659,7 @@ def uninstall_cursor():
|
|
|
3662
4659
|
for s in SKILLS:
|
|
3663
4660
|
unlink_tezgah(os.path.join(CURSOR, "skills", s))
|
|
3664
4661
|
drop_managed_hook_entries(os.path.join(CURSOR, "hooks.json"))
|
|
4662
|
+
drop_if_empty(os.path.join(CURSOR, "hooks.json"), {"version": 1})
|
|
3665
4663
|
mpath = os.path.join(CURSOR, "mcp.json")
|
|
3666
4664
|
mcfg = read_json(mpath, None)
|
|
3667
4665
|
if mcfg and isinstance(mcfg.get("mcpServers"), dict):
|
|
@@ -3676,9 +4674,255 @@ def uninstall_cursor():
|
|
|
3676
4674
|
cfg = read_json(cc, None)
|
|
3677
4675
|
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(
|
|
3678
4676
|
cfg.get("statusLine") or {}):
|
|
3679
|
-
|
|
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)
|
|
3680
4681
|
write_json(cc, cfg)
|
|
3681
4682
|
print("%s removed tezgah statusLine from %s" % (OK, cc))
|
|
4683
|
+
drop_if_empty(cc, {"version": 1})
|
|
4684
|
+
|
|
4685
|
+
|
|
4686
|
+
def uninstall_qwen():
|
|
4687
|
+
print("\nqwen:")
|
|
4688
|
+
for s in SKILLS:
|
|
4689
|
+
unlink_tezgah(os.path.join(QWEN, "skills", s))
|
|
4690
|
+
path = os.path.join(QWEN, "settings.json")
|
|
4691
|
+
drop_managed_hook_entries(path)
|
|
4692
|
+
cfg = read_json(path, None)
|
|
4693
|
+
ui = cfg.get("ui") if isinstance(cfg, dict) else None
|
|
4694
|
+
if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
|
|
4695
|
+
restore_prior(path, cfg, "ui/statusLine")
|
|
4696
|
+
write_json(path, cfg)
|
|
4697
|
+
print("%s removed tezgah statusLine from %s" % (OK, path))
|
|
4698
|
+
drop_if_empty(path)
|
|
4699
|
+
strip_managed_md(os.path.join(QWEN, "QWEN.md"))
|
|
4700
|
+
|
|
4701
|
+
|
|
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:
|
|
4720
|
+
backup(path)
|
|
4721
|
+
os.remove(path)
|
|
4722
|
+
print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
|
|
4723
|
+
forget_prior(path)
|
|
4724
|
+
|
|
4725
|
+
|
|
4726
|
+
def uninstall_droid():
|
|
4727
|
+
print("\ndroid:")
|
|
4728
|
+
for s in SKILLS:
|
|
4729
|
+
unlink_tezgah(os.path.join(DROID, "skills", s))
|
|
4730
|
+
path = os.path.join(DROID, "hooks.json")
|
|
4731
|
+
data = read_json(path, None)
|
|
4732
|
+
if isinstance(data, dict):
|
|
4733
|
+
kept = {}
|
|
4734
|
+
for ev, arr in data.items():
|
|
4735
|
+
if isinstance(arr, list):
|
|
4736
|
+
arr = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
4737
|
+
if not arr:
|
|
4738
|
+
continue
|
|
4739
|
+
kept[ev] = arr
|
|
4740
|
+
if kept == data:
|
|
4741
|
+
print("%s no tezgah hook entries in %s" % (OK, path))
|
|
4742
|
+
elif kept:
|
|
4743
|
+
write_json(path, kept)
|
|
4744
|
+
print("%s removed tezgah hook entries from %s" % (OK, path))
|
|
4745
|
+
else:
|
|
4746
|
+
# an empty hooks.json would still hide the `hooks` key of settings.json
|
|
4747
|
+
backup(path)
|
|
4748
|
+
os.remove(path)
|
|
4749
|
+
print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
|
|
4750
|
+
spath = os.path.join(DROID, "settings.json")
|
|
4751
|
+
cfg = read_json(spath, None)
|
|
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))
|
|
4765
|
+
elif cfg is None and os.path.exists(spath):
|
|
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")
|
|
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
|
|
4784
|
+
if new is not None:
|
|
4785
|
+
write_text(spath, new)
|
|
4786
|
+
print("%s removed tezgah statusLine from %s" % (OK, spath))
|
|
4787
|
+
drop_if_empty(spath)
|
|
4788
|
+
strip_managed_md(os.path.join(DROID, "AGENTS.md"))
|
|
4789
|
+
|
|
4790
|
+
|
|
4791
|
+
def uninstall_commandcode():
|
|
4792
|
+
print("\ncommandcode:")
|
|
4793
|
+
for s in SKILLS:
|
|
4794
|
+
unlink_tezgah(os.path.join(COMMANDCODE, "skills", s))
|
|
4795
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
4796
|
+
drop_managed_hook_entries(path)
|
|
4797
|
+
cfg = read_json(path, None)
|
|
4798
|
+
attr = cfg.get("attribution") if isinstance(cfg, dict) else None
|
|
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)
|
|
4801
|
+
if isinstance(attr, dict) and attr.get("commit") == "":
|
|
4802
|
+
restore_prior(path, cfg, "attribution/commit")
|
|
4803
|
+
write_json(path, cfg)
|
|
4804
|
+
print("%s removed tezgah's attribution.commit from %s" % (OK, path))
|
|
4805
|
+
drop_if_empty(path)
|
|
4806
|
+
strip_managed_md(os.path.join(COMMANDCODE, "AGENTS.md"))
|
|
4807
|
+
|
|
4808
|
+
|
|
4809
|
+
def uninstall_copilot():
|
|
4810
|
+
print("\ncopilot:")
|
|
4811
|
+
for s in SKILLS:
|
|
4812
|
+
unlink_tezgah(os.path.join(COPILOT, "skills", s))
|
|
4813
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
4814
|
+
if os.path.exists(path):
|
|
4815
|
+
backup(path)
|
|
4816
|
+
os.remove(path)
|
|
4817
|
+
print("%s removed %s" % (OK, path))
|
|
4818
|
+
spath = os.path.join(COPILOT, "settings.json")
|
|
4819
|
+
cfg = read_json(spath, None)
|
|
4820
|
+
if isinstance(cfg, dict):
|
|
4821
|
+
if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
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)
|
|
4825
|
+
if cfg.get("includeCoAuthoredBy") is False:
|
|
4826
|
+
restore_prior(spath, cfg, "includeCoAuthoredBy")
|
|
4827
|
+
if write_json(spath, cfg):
|
|
4828
|
+
print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
|
|
4829
|
+
% (OK, spath))
|
|
4830
|
+
drop_if_empty(spath)
|
|
4831
|
+
strip_managed_md(os.path.join(COPILOT, "copilot-instructions.md"))
|
|
4832
|
+
|
|
4833
|
+
|
|
4834
|
+
def uninstall_gemini():
|
|
4835
|
+
"""Delete tezgah's extension dir (its skills are links, unlinked, never
|
|
4836
|
+
followed) and the GEMINI.md block unless Antigravity still reads it."""
|
|
4837
|
+
print("\ngemini:")
|
|
4838
|
+
if os.path.lexists(GEMINI_EXT):
|
|
4839
|
+
for name in ("gemini-extension.json", os.path.join("hooks", "hooks.json")):
|
|
4840
|
+
backup(os.path.join(GEMINI_EXT, name))
|
|
4841
|
+
shutil.rmtree(GEMINI_EXT)
|
|
4842
|
+
print("%s removed %s" % (OK, GEMINI_EXT))
|
|
4843
|
+
if gemini_md_kept("gemini"):
|
|
4844
|
+
print("%s %s keeps the tezgah block: another armed host reads it"
|
|
4845
|
+
% (OK, os.path.join(GEMINI, "GEMINI.md")))
|
|
4846
|
+
else:
|
|
4847
|
+
strip_managed_md(os.path.join(GEMINI, "GEMINI.md"))
|
|
4848
|
+
|
|
4849
|
+
|
|
4850
|
+
def uninstall_antigravity():
|
|
4851
|
+
"""Take the `tezgah` name out of ~/.gemini/config/hooks.json (the user's
|
|
4852
|
+
other names stay), the statusLine, the skill links, and the GEMINI.md block
|
|
4853
|
+
unless Gemini CLI still reads it."""
|
|
4854
|
+
print("\nantigravity:")
|
|
4855
|
+
for s in SKILLS:
|
|
4856
|
+
unlink_tezgah(os.path.join(ANTIGRAVITY, "skills", s))
|
|
4857
|
+
data = read_json(AGY_HOOKS, None)
|
|
4858
|
+
if isinstance(data, dict) and "tezgah" in data:
|
|
4859
|
+
data.pop("tezgah")
|
|
4860
|
+
write_json(AGY_HOOKS, data)
|
|
4861
|
+
print("%s removed tezgah's hooks from %s" % (OK, AGY_HOOKS))
|
|
4862
|
+
drop_if_empty(AGY_HOOKS)
|
|
4863
|
+
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
4864
|
+
cfg = read_json(spath, None)
|
|
4865
|
+
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4866
|
+
restore_prior(spath, cfg, "statusLine")
|
|
4867
|
+
write_json(spath, cfg)
|
|
4868
|
+
print("%s removed tezgah's statusLine from %s" % (OK, spath))
|
|
4869
|
+
drop_if_empty(spath)
|
|
4870
|
+
rules = STATIC_RULES["antigravity"]()
|
|
4871
|
+
if gemini_md_kept("antigravity"):
|
|
4872
|
+
print("%s %s keeps the tezgah block: another armed host reads it" % (OK, rules))
|
|
4873
|
+
else:
|
|
4874
|
+
strip_managed_md(rules)
|
|
4875
|
+
|
|
4876
|
+
|
|
4877
|
+
def uninstall_goose():
|
|
4878
|
+
"""Delete tezgah's plugin dir (its skills are links, unlinked, never
|
|
4879
|
+
followed) and the .goosehints block; the rest of ~/.agents is the user's."""
|
|
4880
|
+
print("\ngoose:")
|
|
4881
|
+
if os.path.lexists(GOOSE_PLUGIN):
|
|
4882
|
+
for name in ("plugin.json", os.path.join("hooks", "hooks.json")):
|
|
4883
|
+
backup(os.path.join(GOOSE_PLUGIN, name))
|
|
4884
|
+
shutil.rmtree(GOOSE_PLUGIN)
|
|
4885
|
+
print("%s removed %s" % (OK, GOOSE_PLUGIN))
|
|
4886
|
+
strip_managed_md(STATIC_RULES["goose"]())
|
|
4887
|
+
|
|
4888
|
+
|
|
4889
|
+
def amp_plugin_ours():
|
|
4890
|
+
"""True when the file at AMP_PLUGIN is tezgah's: a fresh render, or one an
|
|
4891
|
+
older tree rendered (it starts with AMP_HEADER). A user's own plugin of
|
|
4892
|
+
that name is never removed."""
|
|
4893
|
+
try:
|
|
4894
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
4895
|
+
text = fh.read()
|
|
4896
|
+
except OSError:
|
|
4897
|
+
return False
|
|
4898
|
+
return text == ta.amp_plugin(HERE) or text.startswith(ta.AMP_HEADER)
|
|
4899
|
+
|
|
4900
|
+
|
|
4901
|
+
def uninstall_amp():
|
|
4902
|
+
"""Delete tezgah's plugin file (only when it is tezgah's), the skill links,
|
|
4903
|
+
the AGENTS.md block, and each commit-trailer key while it is still false."""
|
|
4904
|
+
print("\namp:")
|
|
4905
|
+
if amp_plugin_ours():
|
|
4906
|
+
backup(AMP_PLUGIN)
|
|
4907
|
+
os.remove(AMP_PLUGIN)
|
|
4908
|
+
print("%s removed %s" % (OK, AMP_PLUGIN))
|
|
4909
|
+
elif os.path.lexists(AMP_PLUGIN):
|
|
4910
|
+
print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
|
|
4911
|
+
for s in SKILLS:
|
|
4912
|
+
unlink_tezgah(os.path.join(AMP, "skills", s))
|
|
4913
|
+
spath, settings = amp_settings("--uninstall")
|
|
4914
|
+
if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
|
|
4915
|
+
for key in AMP_ATTRIBUTION:
|
|
4916
|
+
if settings.get(key) is False:
|
|
4917
|
+
# a value the user had before the install comes back (OQ-15)
|
|
4918
|
+
restore_prior(spath, settings, key)
|
|
4919
|
+
write_json(spath, settings)
|
|
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:
|
|
4924
|
+
drop_if_empty(spath)
|
|
4925
|
+
strip_managed_md(STATIC_RULES["amp"]())
|
|
3682
4926
|
|
|
3683
4927
|
|
|
3684
4928
|
def uninstall_dsh():
|
|
@@ -3714,8 +4958,15 @@ def uninstall_omp():
|
|
|
3714
4958
|
if isinstance(cfg, dict):
|
|
3715
4959
|
servers = cfg.get("mcpServers")
|
|
3716
4960
|
if isinstance(servers, dict):
|
|
3717
|
-
for name in [
|
|
4961
|
+
for name in [TEZGAH_MCP_NAME] + app_mcp_names():
|
|
3718
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")
|
|
3719
4970
|
if not servers:
|
|
3720
4971
|
cfg.pop("mcpServers", None)
|
|
3721
4972
|
# $schema was written by install_omp and nothing else: a file left
|
|
@@ -3731,9 +4982,41 @@ def uninstall_omp():
|
|
|
3731
4982
|
strip_managed_md(os.path.join(OMP_AGENT, "RULES.md"))
|
|
3732
4983
|
|
|
3733
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
|
+
|
|
3734
5013
|
UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
|
|
3735
5014
|
"opencode": uninstall_opencode, "cursor": uninstall_cursor,
|
|
3736
|
-
"dsh": uninstall_dsh, "omp": uninstall_omp
|
|
5015
|
+
"dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
|
|
5016
|
+
"droid": uninstall_droid, "commandcode": uninstall_commandcode,
|
|
5017
|
+
"copilot": uninstall_copilot, "gemini": uninstall_gemini,
|
|
5018
|
+
"antigravity": uninstall_antigravity, "goose": uninstall_goose,
|
|
5019
|
+
"amp": uninstall_amp}
|
|
3737
5020
|
|
|
3738
5021
|
|
|
3739
5022
|
def uninstall(hosts, dry_run=False):
|
|
@@ -3755,6 +5038,7 @@ def uninstall(hosts, dry_run=False):
|
|
|
3755
5038
|
repository's `.tezgah/` research state. A partial run (some armed hosts
|
|
3756
5039
|
stay) also keeps config.json and the install tree, because the hosts still
|
|
3757
5040
|
armed read both at runtime."""
|
|
5041
|
+
global UNINSTALLING
|
|
3758
5042
|
armed = tp.config().get("hosts") or []
|
|
3759
5043
|
full = not armed or set(hosts) >= set(armed)
|
|
3760
5044
|
if dry_run:
|
|
@@ -3786,12 +5070,33 @@ def uninstall(hosts, dry_run=False):
|
|
|
3786
5070
|
print("%s gate stays armed for the hosts this run does not touch" % OK)
|
|
3787
5071
|
print("uninstalling for: %s (%s run)\n"
|
|
3788
5072
|
% (", ".join(hosts) or "none detected", "full" if full else "partial"))
|
|
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))
|
|
3789
5083
|
uninstall_common()
|
|
3790
5084
|
for h in hosts:
|
|
3791
5085
|
UNINSTALLERS[h]()
|
|
3792
5086
|
if "codex" in hosts and "cursor" in (armed or []) and "cursor" not in hosts:
|
|
3793
5087
|
print("\ncursor (its skills came through ~/.codex/skills):")
|
|
3794
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)]
|
|
3795
5100
|
if "claude" in hosts:
|
|
3796
5101
|
print("\nclaude plugin copy:")
|
|
3797
5102
|
remove_plugin_copies()
|
|
@@ -3810,6 +5115,7 @@ def uninstall(hosts, dry_run=False):
|
|
|
3810
5115
|
write_json(tp.CONFIG, cfg)
|
|
3811
5116
|
print("\n%s still armed: %s keeps its config and the install tree"
|
|
3812
5117
|
% (NO, ", ".join(rest)))
|
|
5118
|
+
remove_created_dirs(created)
|
|
3813
5119
|
remaining = verify_uninstall(hosts, full)
|
|
3814
5120
|
print("\nrestart each host so in-memory wiring unloads.")
|
|
3815
5121
|
pred = predecessors()
|
|
@@ -4128,12 +5434,13 @@ def context_budget_report():
|
|
|
4128
5434
|
|
|
4129
5435
|
LIVE_TIMEOUT = 20
|
|
4130
5436
|
LIVE_DENY_COMMAND = "git commit --no-verify -m x"
|
|
4131
|
-
# The JS half: loads the omp extension
|
|
4132
|
-
#
|
|
4133
|
-
# it), registers its handlers against
|
|
4134
|
-
#
|
|
4135
|
-
#
|
|
4136
|
-
# root, session id, command
|
|
5437
|
+
# The JS half: loads the omp extension or the Amp plugin
|
|
5438
|
+
# (`--experimental-strip-types`, the .ts as their bun would) or the opencode
|
|
5439
|
+
# plugin (an ES module, as opencode imports it), registers its handlers against
|
|
5440
|
+
# inert host objects and fires one handler: the tool-result one, or with a
|
|
5441
|
+
# command the tool-call one, printing what it answered (omp, Amp) or what it
|
|
5442
|
+
# threw (opencode) as JSON. argv: host, module path, root, session id, command
|
|
5443
|
+
# ("" for the tool result).
|
|
4137
5444
|
LIVE_DRIVER = r"""
|
|
4138
5445
|
const [host, mod, root, sid, cmd] = process.argv.slice(1);
|
|
4139
5446
|
const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
|
|
@@ -4154,6 +5461,25 @@ const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
|
|
|
4154
5461
|
}
|
|
4155
5462
|
await handlers.tool_result({ toolName: "bash", input: { command: "true" },
|
|
4156
5463
|
isError: false, content: "ok" }, ctx);
|
|
5464
|
+
} else if (host === "amp") {
|
|
5465
|
+
// Amp's registry, its two helpers the plugin reads, and a thread context;
|
|
5466
|
+
// no status item (`experimental` absent), so no status spawn either
|
|
5467
|
+
const handlers = {};
|
|
5468
|
+
m.default(new Proxy({ on: (n, fn) => { handlers[n] = fn; }, experimental: undefined,
|
|
5469
|
+
system: { workspaceRoot: root },
|
|
5470
|
+
helpers: { filePathFromURI: (u) => String(u),
|
|
5471
|
+
shellCommandFromToolCall: (e) => ({ command: e.input.cmd }) } },
|
|
5472
|
+
{ get: (t, k) => (k in t ? t[k] : noop) }));
|
|
5473
|
+
const want = cmd ? "tool.call" : "tool.result";
|
|
5474
|
+
if (!handlers[want]) throw new Error("the plugin registered no " + want + " handler");
|
|
5475
|
+
const ctx = { thread: { id: sid, parentThreadID: async () => null }, ui: noop };
|
|
5476
|
+
const event = { tool: "Bash", input: { cmd: cmd || "true" }, toolUseID: "live",
|
|
5477
|
+
thread: { id: sid } };
|
|
5478
|
+
if (cmd) {
|
|
5479
|
+
process.stdout.write(JSON.stringify((await handlers[want](event, ctx)) || {}));
|
|
5480
|
+
return;
|
|
5481
|
+
}
|
|
5482
|
+
await handlers[want](Object.assign(event, { status: "done", output: "ok" }), ctx);
|
|
4157
5483
|
} else {
|
|
4158
5484
|
const hooks = await (m.Tezgah || m.default)({ directory: root, worktree: root,
|
|
4159
5485
|
client: noop, $: noop });
|
|
@@ -4316,6 +5642,123 @@ def live_plan(host, deny=False):
|
|
|
4316
5642
|
if registered is None:
|
|
4317
5643
|
return plan + ("registration unread: the omp CLI did not answer",)
|
|
4318
5644
|
return plan
|
|
5645
|
+
if host == "qwen":
|
|
5646
|
+
# the hook command Qwen runs for its shell tool, fed Qwen's own payload;
|
|
5647
|
+
# no qwen binary and no model call (tests/e2e_qwen.py drives the binary)
|
|
5648
|
+
path = os.path.join(QWEN, "settings.json")
|
|
5649
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event),
|
|
5650
|
+
"run_shell_command")
|
|
5651
|
+
if not cmd:
|
|
5652
|
+
return ("miss", "no tezgah %s command for run_shell_command in %s"
|
|
5653
|
+
% (event, path))
|
|
5654
|
+
payload = {"hook_event_name": event, "tool_name": "run_shell_command",
|
|
5655
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5656
|
+
if not deny:
|
|
5657
|
+
payload["tool_response"] = {"llmContent": "", "returnDisplay": ""}
|
|
5658
|
+
return ("shell", cmd, payload, {})
|
|
5659
|
+
if host == "droid":
|
|
5660
|
+
# the hook command Droid runs for Execute, fed Droid's own payload; no
|
|
5661
|
+
# droid binary and no model call (tests/e2e_droid.py drives the binary)
|
|
5662
|
+
path = os.path.join(DROID, "hooks.json")
|
|
5663
|
+
cmd = _hook_command(read_json(path, {}).get(event), "Execute")
|
|
5664
|
+
if not cmd:
|
|
5665
|
+
return ("miss", "no tezgah %s command for Execute in %s" % (event, path))
|
|
5666
|
+
payload = {"hook_event_name": event, "tool_name": "Execute",
|
|
5667
|
+
"permission_mode": "auto-low",
|
|
5668
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5669
|
+
if not deny:
|
|
5670
|
+
payload["tool_response"] = "\n\n[Process exited with code 0]"
|
|
5671
|
+
return ("shell", cmd, payload, {})
|
|
5672
|
+
if host == "commandcode":
|
|
5673
|
+
# the hook command Command Code runs for its shell tool (the matcher sees
|
|
5674
|
+
# the display name SHELL, case-insensitively), fed its own payload; no
|
|
5675
|
+
# binary and no model call
|
|
5676
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
5677
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell")
|
|
5678
|
+
if not cmd:
|
|
5679
|
+
return ("miss", "no tezgah %s command for shell in %s" % (event, path))
|
|
5680
|
+
payload = {"hook_event_name": event, "tool_name": "shell_command",
|
|
5681
|
+
"tool_display_name": "SHELL", "permission_mode": "default",
|
|
5682
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5683
|
+
if not deny:
|
|
5684
|
+
payload["tool_response"] = ""
|
|
5685
|
+
return ("shell", cmd, payload, {})
|
|
5686
|
+
if host == "copilot":
|
|
5687
|
+
# the hook command Copilot runs for its shell tool (a PascalCase
|
|
5688
|
+
# PreToolUse sees the Claude name Bash), fed its own payload; no binary
|
|
5689
|
+
# and no model call
|
|
5690
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
5691
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "Bash",
|
|
5692
|
+
own=True)
|
|
5693
|
+
if not cmd:
|
|
5694
|
+
return ("miss", "no tezgah %s command for Bash in %s" % (event, path))
|
|
5695
|
+
payload = {"hook_event_name": event, "tool_name": "Bash",
|
|
5696
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5697
|
+
if not deny:
|
|
5698
|
+
payload["tool_result"] = {"result_type": "success",
|
|
5699
|
+
"text_result_for_llm": "<exited with exit code 0>"}
|
|
5700
|
+
return ("shell", cmd, payload, {})
|
|
5701
|
+
if host == "gemini":
|
|
5702
|
+
# the hook command Gemini runs for its shell tool (BeforeTool/AfterTool),
|
|
5703
|
+
# fed Gemini's own payload; no binary and no model call
|
|
5704
|
+
path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
|
|
5705
|
+
name = "BeforeTool" if deny else "AfterTool"
|
|
5706
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(name),
|
|
5707
|
+
"run_shell_command", own=True)
|
|
5708
|
+
if not cmd:
|
|
5709
|
+
return ("miss", "no tezgah %s command for run_shell_command in %s"
|
|
5710
|
+
% (name, path))
|
|
5711
|
+
payload = {"hook_event_name": name, "tool_name": "run_shell_command",
|
|
5712
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5713
|
+
if not deny:
|
|
5714
|
+
payload["tool_response"] = {"llmContent": "Output: (empty)",
|
|
5715
|
+
"returnDisplay": ""}
|
|
5716
|
+
return ("shell", cmd, payload, {})
|
|
5717
|
+
if host == "antigravity":
|
|
5718
|
+
# the hook command Antigravity runs for run_command, under the `tezgah`
|
|
5719
|
+
# name of ~/.gemini/config/hooks.json; it carries the event as argv[1],
|
|
5720
|
+
# since the camelCase payload names none. No binary and no model call
|
|
5721
|
+
cmd = _hook_command((read_json(AGY_HOOKS, {}).get("tezgah") or {}).get(event),
|
|
5722
|
+
"run_command", own=True)
|
|
5723
|
+
if not cmd:
|
|
5724
|
+
return ("miss", "no tezgah %s command for run_command in %s" % (event, AGY_HOOKS))
|
|
5725
|
+
payload = {"toolCall": {"name": "run_command", "args": {
|
|
5726
|
+
"CommandLine": LIVE_DENY_COMMAND if deny else "true", "WaitMsBeforeAsync": 5000}},
|
|
5727
|
+
"stepIdx": 0}
|
|
5728
|
+
if not deny:
|
|
5729
|
+
payload["error"] = ""
|
|
5730
|
+
return ("shell", cmd, payload, {})
|
|
5731
|
+
if host == "goose":
|
|
5732
|
+
# the hook command Goose runs for its `shell` tool, from tezgah's plugin
|
|
5733
|
+
# hooks.json, fed Goose's own payload (the event in `event`, the
|
|
5734
|
+
# directory as `working_dir`); no binary and no model call
|
|
5735
|
+
path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
|
|
5736
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell",
|
|
5737
|
+
own=True)
|
|
5738
|
+
if not cmd:
|
|
5739
|
+
return ("miss", "no tezgah %s command for shell in %s" % (event, path))
|
|
5740
|
+
return ("shell", cmd, {"event": event, "tool_name": "shell",
|
|
5741
|
+
"matcher_context": "shell", "tool_call_id": "tezgah-live",
|
|
5742
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny
|
|
5743
|
+
else "true"}}, {})
|
|
5744
|
+
if host == "amp":
|
|
5745
|
+
# the installed plugin itself, loaded by node through LIVE_DRIVER and fed
|
|
5746
|
+
# Amp's own event shape (`tool`, `input.cmd`, `thread.id`); the hook it
|
|
5747
|
+
# spawns is the one Amp would spawn
|
|
5748
|
+
try:
|
|
5749
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
5750
|
+
text = fh.read()
|
|
5751
|
+
except OSError:
|
|
5752
|
+
return ("miss", "no plugin at %s" % AMP_PLUGIN)
|
|
5753
|
+
if text != ta.amp_plugin(HERE):
|
|
5754
|
+
return ("miss", "%s differs from a fresh render of this tree's plugin "
|
|
5755
|
+
"- re-run `tezgah-setup --install --hosts amp`" % AMP_PLUGIN)
|
|
5756
|
+
node = _node_for_ts()
|
|
5757
|
+
if not node:
|
|
5758
|
+
return ("unverified", "no node 22.6+ to load %s" % AMP_PLUGIN)
|
|
5759
|
+
# always unverified: Amp's own Bun loader is not what ran it
|
|
5760
|
+
return ("node", [node, "--experimental-strip-types", "--no-warnings"], AMP_PLUGIN,
|
|
5761
|
+
"Amp's own plugin loader is not driven here")
|
|
4319
5762
|
return ("unverified", "no live probe for this host")
|
|
4320
5763
|
|
|
4321
5764
|
|
|
@@ -4333,6 +5776,21 @@ def _denied(host, stdout):
|
|
|
4333
5776
|
return got.get("block") is True
|
|
4334
5777
|
if host == "opencode":
|
|
4335
5778
|
return bool(got.get("threw"))
|
|
5779
|
+
if host == "copilot":
|
|
5780
|
+
# top level, not wrapped (hooks-reference "preToolUse decision control")
|
|
5781
|
+
return got.get("permissionDecision") == "deny"
|
|
5782
|
+
if host == "gemini":
|
|
5783
|
+
# top level (hooks reference "BeforeTool"; `block` is its alias)
|
|
5784
|
+
return got.get("decision") in ("deny", "block")
|
|
5785
|
+
if host == "antigravity":
|
|
5786
|
+
# top level; `decision` is required, so an allow answers too (docs/hooks)
|
|
5787
|
+
return got.get("decision") == "deny"
|
|
5788
|
+
if host == "goose":
|
|
5789
|
+
# top level, stdout starting with `{` (hooks "Blocking a Tool Call")
|
|
5790
|
+
return got.get("decision") == "block"
|
|
5791
|
+
if host == "amp":
|
|
5792
|
+
# ToolCallResult (plugin-api): the call is refused, the turn goes on
|
|
5793
|
+
return got.get("action") == "reject-and-continue"
|
|
4336
5794
|
return (got.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny"
|
|
4337
5795
|
|
|
4338
5796
|
|
|
@@ -4365,7 +5823,13 @@ def live_probe(host, deny=False):
|
|
|
4365
5823
|
if plan[0] == "shell":
|
|
4366
5824
|
_kind, cmd, payload, extra = plan
|
|
4367
5825
|
env.update(extra)
|
|
4368
|
-
|
|
5826
|
+
# Antigravity's envelope is camelCase, with no session_id or cwd;
|
|
5827
|
+
# Goose names the directory `working_dir`
|
|
5828
|
+
payload = (dict(payload, conversationId=sid, workspacePaths=[root])
|
|
5829
|
+
if host == "antigravity" else
|
|
5830
|
+
dict(payload, session_id=sid, working_dir=root)
|
|
5831
|
+
if host == "goose" else
|
|
5832
|
+
dict(payload, session_id=sid, conversation_id=sid, cwd=root))
|
|
4369
5833
|
proc = subprocess.run(cmd, shell=True, input=json.dumps(payload),
|
|
4370
5834
|
capture_output=True, text=True, env=env,
|
|
4371
5835
|
cwd=root, timeout=LIVE_TIMEOUT)
|
|
@@ -4438,7 +5902,9 @@ ARMING_ROWS = ("hooks.json wired", "plugin wired (link or copy)",
|
|
|
4438
5902
|
"carries the contract", "contract in instructions",
|
|
4439
5903
|
"claude-code hooks bridge in cordis.patch.yml",
|
|
4440
5904
|
"session, prompt, tool and stop hooks wired",
|
|
4441
|
-
"hook registered in omp extensions", "plugin copy current"
|
|
5905
|
+
"hook registered in omp extensions", "plugin copy current",
|
|
5906
|
+
"settings.json hooks wired", "hooks/tezgah.json wired",
|
|
5907
|
+
"plugin tezgah.ts current")
|
|
4442
5908
|
|
|
4443
5909
|
|
|
4444
5910
|
def arming_miss(label, good):
|
|
@@ -4704,14 +6170,17 @@ def claude_mcp_rows(copies):
|
|
|
4704
6170
|
return rows
|
|
4705
6171
|
|
|
4706
6172
|
|
|
4707
|
-
def _hooks_has(path, marker, events=None):
|
|
6173
|
+
def _hooks_has(path, marker, events=None, bare=False, key="hooks"):
|
|
4708
6174
|
"""True when `marker` is wired under every event in `events`, or under any
|
|
4709
|
-
event when `events` is None.
|
|
6175
|
+
event when `events` is None. `bare`: the file is keyed by event at the top
|
|
6176
|
+
(Droid's hooks.json); `key`: the top-level name the events sit under
|
|
6177
|
+
(`hooks`, or Antigravity's `tezgah`).
|
|
4710
6178
|
|
|
4711
6179
|
A row that one event can satisfy answers the wrong question: a manifest
|
|
4712
6180
|
carrying tezgah's command under SessionStart and not under Stop reads as
|
|
4713
6181
|
wired, and Stop is the integrity refusal."""
|
|
4714
|
-
|
|
6182
|
+
data = read_json(path, {}) or {}
|
|
6183
|
+
hooks = data if bare else data.get(key) or {}
|
|
4715
6184
|
if events is None:
|
|
4716
6185
|
return any(marker in json.dumps(e) for arr in hooks.values() for e in arr)
|
|
4717
6186
|
return all(any(marker in json.dumps(e) for e in (hooks.get(ev) or []))
|
|
@@ -4850,6 +6319,156 @@ def host_checks_cursor():
|
|
|
4850
6319
|
]
|
|
4851
6320
|
|
|
4852
6321
|
|
|
6322
|
+
def host_checks_qwen():
|
|
6323
|
+
path = os.path.join(QWEN, "settings.json")
|
|
6324
|
+
# a relocated home (QWEN_HOME) is named, as codex's is
|
|
6325
|
+
where = " in %s" % QWEN if QWEN != os.path.join(tp.HOME, ".qwen") else ""
|
|
6326
|
+
ui = read_json(path, {}).get("ui")
|
|
6327
|
+
return [
|
|
6328
|
+
("settings.json hooks wired%s" % where,
|
|
6329
|
+
_hooks_has(path, "tezgah-qwen-hook", [ev for ev, _m, _t in QWEN_HOOK_EVENTS])),
|
|
6330
|
+
("skills linked%s" % where, skills_linked(os.path.join(QWEN, "skills"))),
|
|
6331
|
+
("QWEN.md carries the contract (matches a fresh render)", rules_current("qwen")),
|
|
6332
|
+
("statusLine in settings.json ui",
|
|
6333
|
+
"tezgah-statusline" in json.dumps((ui if isinstance(ui, dict) else {})
|
|
6334
|
+
.get("statusLine") or {})),
|
|
6335
|
+
("qwen CLI on PATH", bool(tp.which_user("qwen"))),
|
|
6336
|
+
]
|
|
6337
|
+
|
|
6338
|
+
|
|
6339
|
+
def host_checks_droid():
|
|
6340
|
+
path = os.path.join(DROID, "hooks.json")
|
|
6341
|
+
why = droid_hooks_blocked()
|
|
6342
|
+
settings = read_jsonc(os.path.join(DROID, "settings.json")) or {}
|
|
6343
|
+
status = settings.get("statusLine")
|
|
6344
|
+
return [
|
|
6345
|
+
("hooks.json wired" + (" (not written: %s)" % why if why else ""),
|
|
6346
|
+
_hooks_has(path, "tezgah-droid-hook", [ev for ev, _m, _t in DROID_HOOK_EVENTS],
|
|
6347
|
+
bare=True)),
|
|
6348
|
+
("skills linked", skills_linked(os.path.join(DROID, "skills"))),
|
|
6349
|
+
("AGENTS.md carries the contract (matches a fresh render)", rules_current("droid")),
|
|
6350
|
+
("statusLine in settings.json", "tezgah-statusline" in json.dumps(status or {})),
|
|
6351
|
+
("includeCoAuthoredByDroid off in settings.json",
|
|
6352
|
+
settings.get("includeCoAuthoredByDroid") is False),
|
|
6353
|
+
("droid CLI on PATH", bool(tp.which_user("droid"))),
|
|
6354
|
+
]
|
|
6355
|
+
|
|
6356
|
+
|
|
6357
|
+
def host_checks_commandcode():
|
|
6358
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
6359
|
+
attr = read_json(path, {}).get("attribution")
|
|
6360
|
+
return [
|
|
6361
|
+
("settings.json hooks wired",
|
|
6362
|
+
_hooks_has(path, "tezgah-commandcode-hook",
|
|
6363
|
+
[ev for ev, _m, _t in COMMANDCODE_HOOK_EVENTS])),
|
|
6364
|
+
("skills linked", skills_linked(os.path.join(COMMANDCODE, "skills"))),
|
|
6365
|
+
("AGENTS.md carries the contract (matches a fresh render)",
|
|
6366
|
+
rules_current("commandcode")),
|
|
6367
|
+
("attribution.commit off in settings.json",
|
|
6368
|
+
isinstance(attr, dict) and attr.get("commit") == ""),
|
|
6369
|
+
("command-code CLI on PATH", bool(tp.which_user("command-code"))),
|
|
6370
|
+
]
|
|
6371
|
+
|
|
6372
|
+
|
|
6373
|
+
def host_checks_copilot():
|
|
6374
|
+
# a relocated home (COPILOT_HOME) is named, as codex's is
|
|
6375
|
+
where = " in %s" % COPILOT if COPILOT != os.path.join(tp.HOME, ".copilot") else ""
|
|
6376
|
+
settings = read_jsonc(os.path.join(COPILOT, "settings.json")) or {}
|
|
6377
|
+
return [
|
|
6378
|
+
("hooks/tezgah.json wired%s" % where,
|
|
6379
|
+
_hooks_has(os.path.join(COPILOT, "hooks", "tezgah.json"), "tezgah-copilot-hook",
|
|
6380
|
+
[ev for ev, _m, _t in COPILOT_HOOK_EVENTS])),
|
|
6381
|
+
("skills linked%s" % where, skills_linked(os.path.join(COPILOT, "skills"))),
|
|
6382
|
+
("copilot-instructions.md carries the contract (matches a fresh render)",
|
|
6383
|
+
rules_current("copilot")),
|
|
6384
|
+
("statusLine in settings.json",
|
|
6385
|
+
"tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
|
|
6386
|
+
("includeCoAuthoredBy off in settings.json",
|
|
6387
|
+
settings.get("includeCoAuthoredBy") is False),
|
|
6388
|
+
("copilot CLI on PATH", bool(tp.which_user("copilot"))),
|
|
6389
|
+
]
|
|
6390
|
+
|
|
6391
|
+
|
|
6392
|
+
def host_checks_gemini():
|
|
6393
|
+
# a relocated home (GEMINI_CLI_HOME) is named, as codex's is
|
|
6394
|
+
where = " in %s" % GEMINI if GEMINI != os.path.join(tp.HOME, ".gemini") else ""
|
|
6395
|
+
hooks_cfg = (read_jsonc(os.path.join(GEMINI, "settings.json")) or {}).get("hooksConfig")
|
|
6396
|
+
return [
|
|
6397
|
+
# the hooks load only beside a manifest naming the extension
|
|
6398
|
+
("extension hooks/hooks.json wired%s" % where,
|
|
6399
|
+
_hooks_has(os.path.join(GEMINI_EXT, "hooks", "hooks.json"), "tezgah-gemini-hook",
|
|
6400
|
+
[ev for ev, _m, _t in GEMINI_HOOK_EVENTS])
|
|
6401
|
+
and read_json(os.path.join(GEMINI_EXT, "gemini-extension.json"), {})
|
|
6402
|
+
.get("name") == "tezgah"),
|
|
6403
|
+
("skills linked%s" % where, skills_linked(os.path.join(GEMINI_EXT, "skills"))),
|
|
6404
|
+
("GEMINI.md carries the contract (matches a fresh render)", rules_current("gemini")),
|
|
6405
|
+
# `hooksConfig.enabled: false` runs no hook at all (configuration)
|
|
6406
|
+
("hooks left on in settings.json (hooksConfig.enabled)",
|
|
6407
|
+
not (isinstance(hooks_cfg, dict) and hooks_cfg.get("enabled") is False)),
|
|
6408
|
+
("gemini CLI on PATH", bool(tp.which_user("gemini"))),
|
|
6409
|
+
]
|
|
6410
|
+
|
|
6411
|
+
|
|
6412
|
+
def host_checks_antigravity():
|
|
6413
|
+
settings = read_json(os.path.join(ANTIGRAVITY, "settings.json"), {})
|
|
6414
|
+
mine = read_json(AGY_HOOKS, {}).get("tezgah")
|
|
6415
|
+
return [
|
|
6416
|
+
("config/hooks.json wired (the `tezgah` name)",
|
|
6417
|
+
_hooks_has(AGY_HOOKS, "tezgah-antigravity-hook",
|
|
6418
|
+
[ev for ev, _m, _t in ANTIGRAVITY_HOOK_EVENTS], key="tezgah")),
|
|
6419
|
+
# `enabled: false` on a hook name loads none of its events (docs/hooks)
|
|
6420
|
+
("tezgah hook set left enabled in config/hooks.json",
|
|
6421
|
+
not (isinstance(mine, dict) and mine.get("enabled") is False)),
|
|
6422
|
+
("skills linked", skills_linked(os.path.join(ANTIGRAVITY, "skills"))),
|
|
6423
|
+
("GEMINI.md carries the contract (matches a fresh render)",
|
|
6424
|
+
rules_current("antigravity")),
|
|
6425
|
+
("statusLine in antigravity-cli/settings.json",
|
|
6426
|
+
"tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
|
|
6427
|
+
("agy CLI on PATH", bool(tp.which_user("agy"))),
|
|
6428
|
+
]
|
|
6429
|
+
|
|
6430
|
+
|
|
6431
|
+
def host_checks_goose():
|
|
6432
|
+
disabled = read_json(os.path.join(GOOSE, "settings.json"), {}).get("disabledPlugins")
|
|
6433
|
+
return [
|
|
6434
|
+
# the hooks load only from a plugin dir under ~/.agents/plugins
|
|
6435
|
+
("plugin hooks/hooks.json wired (~/.agents/plugins/tezgah)",
|
|
6436
|
+
_hooks_has(os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json"), "tezgah-goose-hook",
|
|
6437
|
+
[ev for ev, _m, _t in GOOSE_HOOK_EVENTS])
|
|
6438
|
+
and read_json(os.path.join(GOOSE_PLUGIN, "plugin.json"), {}).get("name") == "tezgah"),
|
|
6439
|
+
# a plugin named in `disabledPlugins` is skipped at discovery (plugins guide)
|
|
6440
|
+
("tezgah plugin not in disabledPlugins (settings.json)",
|
|
6441
|
+
not (isinstance(disabled, list) and "tezgah" in disabled)),
|
|
6442
|
+
("skills linked", skills_linked(os.path.join(GOOSE_PLUGIN, "skills"))),
|
|
6443
|
+
(".goosehints carries the contract (matches a fresh render)", rules_current("goose")),
|
|
6444
|
+
("goose CLI on PATH", bool(tp.which_user("goose"))),
|
|
6445
|
+
]
|
|
6446
|
+
|
|
6447
|
+
|
|
6448
|
+
def host_checks_amp():
|
|
6449
|
+
try:
|
|
6450
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
6451
|
+
plugin = fh.read()
|
|
6452
|
+
except OSError:
|
|
6453
|
+
plugin = ""
|
|
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)
|
|
6457
|
+
return [
|
|
6458
|
+
# byte for byte, as omp's bridge row: a hand edit or an older tree's
|
|
6459
|
+
# render is not the wiring this tree arms
|
|
6460
|
+
("plugin tezgah.ts current (matches a fresh render): %s" % AMP_PLUGIN,
|
|
6461
|
+
bool(plugin) and plugin == ta.amp_plugin(HERE)),
|
|
6462
|
+
("skills linked", skills_linked(os.path.join(AMP, "skills"))),
|
|
6463
|
+
("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
|
|
6464
|
+
("%s off in %s" % (AMP_ATTRIBUTION[0], name),
|
|
6465
|
+
settings.get(AMP_ATTRIBUTION[0]) is False),
|
|
6466
|
+
("%s off in %s" % (AMP_ATTRIBUTION[1], name),
|
|
6467
|
+
settings.get(AMP_ATTRIBUTION[1]) is False),
|
|
6468
|
+
("amp CLI on PATH", bool(tp.which_user("amp"))),
|
|
6469
|
+
]
|
|
6470
|
+
|
|
6471
|
+
|
|
4853
6472
|
def host_checks_dsh():
|
|
4854
6473
|
try:
|
|
4855
6474
|
with open(DSH_PATCH, encoding="utf-8") as fh:
|
|
@@ -4968,7 +6587,11 @@ def host_checks_omp():
|
|
|
4968
6587
|
|
|
4969
6588
|
HOST_CHECKS = {"claude": host_checks_claude, "codex": host_checks_codex,
|
|
4970
6589
|
"opencode": host_checks_opencode, "cursor": host_checks_cursor,
|
|
4971
|
-
"dsh": host_checks_dsh, "omp": host_checks_omp
|
|
6590
|
+
"dsh": host_checks_dsh, "omp": host_checks_omp, "qwen": host_checks_qwen,
|
|
6591
|
+
"droid": host_checks_droid, "commandcode": host_checks_commandcode,
|
|
6592
|
+
"copilot": host_checks_copilot, "gemini": host_checks_gemini,
|
|
6593
|
+
"antigravity": host_checks_antigravity, "goose": host_checks_goose,
|
|
6594
|
+
"amp": host_checks_amp}
|
|
4972
6595
|
|
|
4973
6596
|
|
|
4974
6597
|
def versions_agree():
|
|
@@ -5682,6 +7305,9 @@ def upgrade(version, dry_run):
|
|
|
5682
7305
|
return 127
|
|
5683
7306
|
import tezgah_update
|
|
5684
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()
|
|
5685
7311
|
proc = subprocess.run(cmd)
|
|
5686
7312
|
if proc.returncode != 0:
|
|
5687
7313
|
print("%s %s exited %d; tezgah was not re-armed"
|
|
@@ -5705,6 +7331,7 @@ def upgrade(version, dry_run):
|
|
|
5705
7331
|
print("%s fetched, not re-armed: %s. Re-arm later with `%s`"
|
|
5706
7332
|
% (NO, tezgah_update.NOT_REARMED["prefix"], " ".join(argv)))
|
|
5707
7333
|
return 1
|
|
7334
|
+
sys.stdout.flush()
|
|
5708
7335
|
return subprocess.run(argv).returncode
|
|
5709
7336
|
|
|
5710
7337
|
|
|
@@ -5774,10 +7401,27 @@ def install_all(hosts, new_roots, deps_off, dry_run, redetect):
|
|
|
5774
7401
|
return 1 if missed or REFUSED else 0
|
|
5775
7402
|
|
|
5776
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
|
+
|
|
5777
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)
|
|
5778
7421
|
ap = argparse.ArgumentParser(description="arm tezgah in every installed host; "
|
|
5779
7422
|
"`tezgah update [--dry-run]` moves this install "
|
|
5780
|
-
"to the newest release and re-arms it"
|
|
7423
|
+
"to the newest release and re-arms it",
|
|
7424
|
+
allow_abbrev=False)
|
|
5781
7425
|
ap.add_argument("--install", action="store_true")
|
|
5782
7426
|
ap.add_argument("--wizard", action="store_true",
|
|
5783
7427
|
help="ask the install questions (a bare run on a terminal does this)")
|
|
@@ -5910,6 +7554,8 @@ def main(argv):
|
|
|
5910
7554
|
paths = refresh_contract()
|
|
5911
7555
|
for path in paths:
|
|
5912
7556
|
print("%s refreshed %s" % (OK, path))
|
|
7557
|
+
if REFUSED:
|
|
7558
|
+
return 1 # each refused file was named above; it is not current
|
|
5913
7559
|
if not paths:
|
|
5914
7560
|
print("contract is current")
|
|
5915
7561
|
return 0
|
|
@@ -5954,8 +7600,8 @@ def main(argv):
|
|
|
5954
7600
|
wizard_mode = (args.wizard or bare_tty) and not args.report
|
|
5955
7601
|
|
|
5956
7602
|
if args.uninstall:
|
|
5957
|
-
|
|
5958
|
-
return
|
|
7603
|
+
# 1 while anything tezgah wrote survives: the verify pass counts it
|
|
7604
|
+
return uninstall(hosts, dry_run=args.dry_run)
|
|
5959
7605
|
|
|
5960
7606
|
# A selection change is its own command: it persists the choice and brings
|
|
5961
7607
|
# every host the install recorded along, so `--enable`/`--disable` reach the
|