@r1z4x/tezgah 1.11.0 → 1.13.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 +333 -1
- package/MANIFEST +22 -0
- package/README.md +4 -3
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-research +39 -4
- package/bin/tezgah-setup +1229 -22
- package/bin/tezgah-task +55 -17
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -3
- package/docs/contract.md +6 -3
- package/docs/environment.md +3 -2
- package/docs/evidence.md +81 -8
- package/docs/gate.md +6 -2
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +297 -15
- package/docs/layers.md +1 -1
- package/docs/models.md +6 -0
- package/docs/operations.md +16 -5
- package/docs/research.md +36 -10
- package/docs/status-line.md +6 -0
- package/docs/testing.md +3 -1
- package/hooks/hooks.json +1 -1
- package/hooks/lang/en.json +11 -0
- package/hooks/lang/tr.json +11 -0
- package/hooks/projects-stop.py +2 -1
- package/hooks/tezgah_agents.py +27 -3
- package/hooks/tezgah_attest.py +79 -10
- package/hooks/tezgah_context.py +21 -8
- package/hooks/tezgah_gate.py +79 -23
- package/hooks/tezgah_integrity.py +212 -38
- package/hooks/tezgah_paths.py +43 -2
- package/hooks/tezgah_policy.py +35 -13
- package/hooks/tezgah_replay.py +8 -2
- package/hooks/tezgah_research.py +167 -24
- package/hooks/tezgah_stack.py +236 -1
- package/hooks/tezgah_task.py +2 -1
- package/hosts/amp/hook.py +217 -0
- package/hosts/amp/tezgah-plugin.ts.in +386 -0
- package/hosts/antigravity/hook.py +255 -0
- package/hosts/codex/hook.py +2 -1
- package/hosts/commandcode/hook.py +239 -0
- package/hosts/copilot/hook.py +284 -0
- package/hosts/cursor/hook.py +2 -1
- 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 +4 -1
- package/hosts/omp/tezgah-hook.ts.in +30 -7
- package/hosts/opencode/plugins/tezgah.js +2 -2
- package/hosts/qwen/hook.py +206 -0
- package/output-styles/tezgah.md +10 -5
- package/package.json +1 -1
- 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/research/SKILL.md +22 -9
- package/skills/tezgah-contract/SKILL.md +19 -6
- package/statusline.py +20 -9
- 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
|
|
@@ -324,6 +347,73 @@ CURSOR_HOOK_EVENTS = {
|
|
|
324
347
|
"afterFileEdit": {"timeout": 10},
|
|
325
348
|
"afterAgentResponse": {"timeout": 5},
|
|
326
349
|
}
|
|
350
|
+
# Qwen Code 0.21.13: a matcher is a regex over the runtime tool id, and a
|
|
351
|
+
# command hook's `timeout` is in MILLISECONDS (60000 by default) - the seconds
|
|
352
|
+
# value the other manifests carry would time every hook out. Stop and
|
|
353
|
+
# SubagentStop run the integrity rule, which may ask a judge, so they get more.
|
|
354
|
+
# Qwen takes MCP tools as `mcp__<server>__<tool>`.
|
|
355
|
+
QWEN_PRETOOL_MATCHER = ("^(run_shell_command|monitor|write_file|edit|replace|notebook_edit|"
|
|
356
|
+
"agent|grep_search|mcp__.*)$")
|
|
357
|
+
QWEN_HOOK_EVENTS = (("SessionStart", None, 5000), ("UserPromptSubmit", None, 5000),
|
|
358
|
+
("PreToolUse", QWEN_PRETOOL_MATCHER, 5000),
|
|
359
|
+
("PostToolUse", None, 5000), ("PostToolUseFailure", None, 5000),
|
|
360
|
+
("SubagentStart", None, 5000), ("SubagentStop", None, 30000),
|
|
361
|
+
("Stop", None, 30000))
|
|
362
|
+
# Factory Droid 0.251.0 (docs.factory.com harness/hooks): a matcher is a
|
|
363
|
+
# case-sensitive regex over the tool id, and `timeout` is in seconds. Droid has
|
|
364
|
+
# no SubagentStart and no failure event. MCP tools are `mcp__<server>__<tool>`.
|
|
365
|
+
DROID_PRETOOL_MATCHER = "^(Execute|Create|Edit|ApplyPatch|Task|Grep|mcp__.*)$"
|
|
366
|
+
DROID_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
367
|
+
("PreToolUse", DROID_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
|
|
368
|
+
("SubagentStop", None, 30), ("Stop", None, 30))
|
|
369
|
+
# Command Code (commandcode.ai/docs/hooks): four events, `timeout` in seconds, and
|
|
370
|
+
# a matcher tested against the tool's display name (SHELL, READ, WRITE, EDIT) -
|
|
371
|
+
# its settings hooks see no other tool, so MCP, subagent and web calls are not
|
|
372
|
+
# gated there. A matcher on SessionStart or Stop stops the hook from firing.
|
|
373
|
+
COMMANDCODE_PRETOOL_MATCHER = "shell|write|edit"
|
|
374
|
+
COMMANDCODE_HOOK_EVENTS = (("SessionStart", None, 5),
|
|
375
|
+
("PreToolUse", COMMANDCODE_PRETOOL_MATCHER, 5),
|
|
376
|
+
("PostToolUse", None, 5), ("Stop", None, 30))
|
|
377
|
+
# GitHub Copilot CLI (docs.github.com hooks-reference): tezgah's own file in the
|
|
378
|
+
# user hooks dir, flat entries with `timeoutSec` in seconds. The PascalCase names
|
|
379
|
+
# get snake_case payloads and Claude tool names; `subagentStart` has no PascalCase
|
|
380
|
+
# form, so its camelCase payload names no event and the command passes it. A
|
|
381
|
+
# PascalCase PreToolUse matcher is anchored and tested against the Claude tool
|
|
382
|
+
# name; an MCP tool is `<server>-<tool>`. A timed-out PreToolUse lets the call
|
|
383
|
+
# through, hence 10 s there.
|
|
384
|
+
COPILOT_PRETOOL_MATCHER = "^(?:Bash|Write|Edit|Agent|Task|Grep|.+-.+)$"
|
|
385
|
+
COPILOT_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
386
|
+
("PreToolUse", COPILOT_PRETOOL_MATCHER, 10), ("PostToolUse", None, 5),
|
|
387
|
+
("PostToolUseFailure", None, 5), ("subagentStart", None, 5),
|
|
388
|
+
("SubagentStop", None, 30), ("Stop", None, 30))
|
|
389
|
+
# Gemini CLI (geminicli hooks reference): `timeout` in MILLISECONDS, a BeforeTool
|
|
390
|
+
# matcher is a regex over the tool name, a lifecycle matcher an exact string (so
|
|
391
|
+
# none). A subagent is a tool of its own name; an MCP tool is
|
|
392
|
+
# `mcp_<server>_<tool>`. AfterAgent runs the integrity rule, which may ask a judge.
|
|
393
|
+
GEMINI_PRETOOL_MATCHER = ("^(run_shell_command|write_file|replace|grep_search|"
|
|
394
|
+
"search_file_content|codebase_investigator|generalist|"
|
|
395
|
+
"invoke_agent|mcp_.*)$")
|
|
396
|
+
GEMINI_HOOK_EVENTS = (("SessionStart", None, 5000), ("BeforeAgent", None, 5000),
|
|
397
|
+
("BeforeTool", GEMINI_PRETOOL_MATCHER, 5000),
|
|
398
|
+
("AfterTool", None, 5000), ("AfterAgent", None, 30000))
|
|
399
|
+
# Antigravity CLI (docs/hooks): `timeout` in seconds; a tool event is a matcher
|
|
400
|
+
# group (a regex over the tool name, "" for every tool), a lifecycle event a bare
|
|
401
|
+
# handler. The payload names no event, so the command does (argv[1]). An MCP
|
|
402
|
+
# tool's name is undocumented, so none is matched. Stop runs the integrity rule.
|
|
403
|
+
ANTIGRAVITY_PRETOOL_MATCHER = ("^(run_command|write_to_file|replace_file_content|"
|
|
404
|
+
"multi_replace_file_content|invoke_subagent|grep_search)$")
|
|
405
|
+
ANTIGRAVITY_HOOK_EVENTS = (("SessionStart", None, 5), ("PreInvocation", None, 5),
|
|
406
|
+
("PreToolUse", ANTIGRAVITY_PRETOOL_MATCHER, 5),
|
|
407
|
+
("PostToolUse", "", 5), ("Stop", None, 30))
|
|
408
|
+
# Goose (docs guides/context-engineering/hooks): `timeout` in seconds, a matcher a
|
|
409
|
+
# regex tested unanchored (so anchored here) against the tool name. The built-in
|
|
410
|
+
# developer tools are bare (`shell`, `write`, `edit`), an extension's tool is
|
|
411
|
+
# `<extension>__<tool>`. Only PreToolUse and Stop can block; Stop runs the
|
|
412
|
+
# integrity rule. `on_failure` is left at its default `allow` (X-D2).
|
|
413
|
+
GOOSE_PRETOOL_MATCHER = "^(shell|write|edit|.+__.+)$"
|
|
414
|
+
GOOSE_HOOK_EVENTS = (("SessionStart", None, 5), ("UserPromptSubmit", None, 5),
|
|
415
|
+
("PreToolUse", GOOSE_PRETOOL_MATCHER, 5), ("PostToolUse", None, 5),
|
|
416
|
+
("PostToolUseFailure", None, 5), ("Stop", None, 30))
|
|
327
417
|
# The feature selection for THIS run: None means "read config.json['features']",
|
|
328
418
|
# and an absent key there means the registry's declared default, which is what
|
|
329
419
|
# makes a config written before this existed behave exactly as it used to.
|
|
@@ -468,14 +558,22 @@ def hook_entries(hosts):
|
|
|
468
558
|
what re-arming would change."""
|
|
469
559
|
out = {}
|
|
470
560
|
for h in hosts:
|
|
471
|
-
if h in ("codex", "cursor"
|
|
472
|
-
|
|
561
|
+
if h in ("codex", "cursor", "qwen", "droid", "commandcode", "copilot", "gemini",
|
|
562
|
+
"antigravity", "goose"):
|
|
563
|
+
groups = {"codex": codex_hook_groups, "cursor": cursor_hook_entries,
|
|
564
|
+
"qwen": qwen_hook_groups, "droid": droid_hook_groups,
|
|
565
|
+
"commandcode": commandcode_hook_groups,
|
|
566
|
+
"copilot": copilot_hook_entries, "gemini": gemini_hook_groups,
|
|
567
|
+
"antigravity": antigravity_hook_groups,
|
|
568
|
+
"goose": goose_hook_groups}[h]()
|
|
473
569
|
out[h] = ta.own_groups({"hooks": {ev: [g] for ev, g in groups.items()}})
|
|
474
570
|
elif h == "opencode":
|
|
475
571
|
src = os.path.join(HERE, "hosts", "opencode", "plugins", "tezgah.js")
|
|
476
572
|
out[h] = {d: src for d in ta.OPENCODE_DIRS}
|
|
477
573
|
elif h == "omp":
|
|
478
574
|
out[h] = {"bridge": omp_bridge()}
|
|
575
|
+
elif h == "amp":
|
|
576
|
+
out[h] = {"plugin": ta.amp_plugin(HERE)}
|
|
479
577
|
else:
|
|
480
578
|
out[h] = ta.registration(h, HERE)[0] or {}
|
|
481
579
|
return out
|
|
@@ -679,7 +777,13 @@ def write_json(path, data):
|
|
|
679
777
|
An unchanged write is not free: it moves a host file's mtime and lays down a
|
|
680
778
|
`.tezgah-bak`, so a command whose whole point is idempotence (a repeated
|
|
681
779
|
--disable) would report a change it never made. Returns True when it wrote."""
|
|
682
|
-
|
|
780
|
+
return write_text(path, json.dumps(data, indent=2) + "\n")
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def write_text(path, text):
|
|
784
|
+
"""write_json's write for text already rendered (a JSON-with-comments file
|
|
785
|
+
edited in place): unchanged bytes skip it, a backup comes first, and the
|
|
786
|
+
file keeps its mode. Returns True when it wrote."""
|
|
683
787
|
try:
|
|
684
788
|
with open(path, encoding="utf-8") as fh:
|
|
685
789
|
if fh.read() == text:
|
|
@@ -689,8 +793,15 @@ def write_json(path, data):
|
|
|
689
793
|
mkdirp(os.path.dirname(path))
|
|
690
794
|
backup(path)
|
|
691
795
|
tmp = path + ".tmp"
|
|
692
|
-
|
|
796
|
+
existed = os.path.exists(path)
|
|
797
|
+
# over an existing file the copy is born owner-only, then takes the file's
|
|
798
|
+
# mode: a settings file holding provider keys is never readable by others,
|
|
799
|
+
# not even between the write and the mode copy
|
|
800
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600 if existed else 0o666)
|
|
801
|
+
with open(fd, "w", encoding="utf-8") as fh:
|
|
693
802
|
fh.write(text)
|
|
803
|
+
if existed:
|
|
804
|
+
shutil.copymode(path, tmp) # an owner-only config stays owner-only
|
|
694
805
|
os.replace(tmp, path)
|
|
695
806
|
return True
|
|
696
807
|
|
|
@@ -727,7 +838,8 @@ def backup_base(path):
|
|
|
727
838
|
|
|
728
839
|
def backup(path):
|
|
729
840
|
"""Copy `path` to `<backup_base>.<timestamp>.tezgah-bak` before a write
|
|
730
|
-
changes it
|
|
841
|
+
changes it, owner-only: a host config can hold provider keys (Qwen's
|
|
842
|
+
settings.json does), and a copy must not be readable where the file was not.
|
|
731
843
|
|
|
732
844
|
The suffix stays `.tezgah-bak`, so managed() and the uninstall cleanup still
|
|
733
845
|
know it; past BACKUP_CAP the middle copies go, never the oldest."""
|
|
@@ -743,6 +855,7 @@ def backup(path):
|
|
|
743
855
|
try:
|
|
744
856
|
mkdirp(os.path.dirname(dst))
|
|
745
857
|
shutil.copy2(path, dst)
|
|
858
|
+
os.chmod(dst, 0o600)
|
|
746
859
|
d, name = os.path.split(base)
|
|
747
860
|
old = sorted(entry for entry in os.listdir(d or ".")
|
|
748
861
|
if (m := BACKUP_RE.fullmatch(entry)) and m.group(1) == name)
|
|
@@ -1098,6 +1211,15 @@ def install_common(new_roots, hosts):
|
|
|
1098
1211
|
("tezgah-render-table", os.path.join(HERE, "skills", "plan-add", "render_table.py")),
|
|
1099
1212
|
("tezgah-codex-hook", os.path.join(HERE, "hosts", "codex", "hook.py")),
|
|
1100
1213
|
("tezgah-cursor-hook", os.path.join(HERE, "hosts", "cursor", "hook.py")),
|
|
1214
|
+
("tezgah-qwen-hook", os.path.join(HERE, "hosts", "qwen", "hook.py")),
|
|
1215
|
+
("tezgah-droid-hook", os.path.join(HERE, "hosts", "droid", "hook.py")),
|
|
1216
|
+
("tezgah-commandcode-hook",
|
|
1217
|
+
os.path.join(HERE, "hosts", "commandcode", "hook.py")),
|
|
1218
|
+
("tezgah-copilot-hook", os.path.join(HERE, "hosts", "copilot", "hook.py")),
|
|
1219
|
+
("tezgah-gemini-hook", os.path.join(HERE, "hosts", "gemini", "hook.py")),
|
|
1220
|
+
("tezgah-antigravity-hook",
|
|
1221
|
+
os.path.join(HERE, "hosts", "antigravity", "hook.py")),
|
|
1222
|
+
("tezgah-goose-hook", os.path.join(HERE, "hosts", "goose", "hook.py")),
|
|
1101
1223
|
("tezgah-statusline", os.path.join(HERE, "statusline.py")),
|
|
1102
1224
|
("tezgah-triage", os.path.join(HERE, "bin", "tezgah-triage")),
|
|
1103
1225
|
("tezgah-migrate", os.path.join(HERE, "bin", "tezgah-migrate")),
|
|
@@ -1613,7 +1735,7 @@ def opencode_contract():
|
|
|
1613
1735
|
def contract_artifacts(hosts):
|
|
1614
1736
|
"""{path: (fresh render, writer)} for every always-on contract artifact
|
|
1615
1737
|
these hosts read: the managed block of each static rules file (Claude,
|
|
1616
|
-
Codex, omp) and opencode's contract and skill routers."""
|
|
1738
|
+
Codex, omp, Qwen, Droid) and opencode's contract and skill routers."""
|
|
1617
1739
|
out = {}
|
|
1618
1740
|
for h in hosts:
|
|
1619
1741
|
if h in STATIC_RULES:
|
|
@@ -2087,6 +2209,470 @@ def install_cursor():
|
|
|
2087
2209
|
restart_hint("cursor", mpath)
|
|
2088
2210
|
|
|
2089
2211
|
|
|
2212
|
+
def qwen_hook_groups():
|
|
2213
|
+
"""{event: the group install_qwen writes}; `hook_entries` plans from it."""
|
|
2214
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-qwen-hook"))
|
|
2215
|
+
out = {}
|
|
2216
|
+
for ev, matcher, timeout in QWEN_HOOK_EVENTS:
|
|
2217
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2218
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2219
|
+
out[ev] = group
|
|
2220
|
+
return out
|
|
2221
|
+
|
|
2222
|
+
|
|
2223
|
+
def install_qwen():
|
|
2224
|
+
"""Qwen Code: hook groups and the status line in `settings.json`, skill
|
|
2225
|
+
links, and the always-on block in `QWEN.md`. settings.json is shared - Orca
|
|
2226
|
+
writes its own hooks there and the user keeps provider keys in it - so only a
|
|
2227
|
+
group naming tezgah is replaced, the test uninstall drops by, and the backup
|
|
2228
|
+
`write_json` lays down is owner-only (`backup`)."""
|
|
2229
|
+
print("\nqwen:")
|
|
2230
|
+
path = os.path.join(QWEN, "settings.json")
|
|
2231
|
+
data = read_config(path, {})
|
|
2232
|
+
if data is not None:
|
|
2233
|
+
hooks = data.setdefault("hooks", {})
|
|
2234
|
+
for ev, group in qwen_hook_groups().items():
|
|
2235
|
+
arr = hooks.setdefault(ev, [])
|
|
2236
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2237
|
+
arr.append(group)
|
|
2238
|
+
# Qwen's command status line: the session JSON on stdin, ANSI kept
|
|
2239
|
+
data.setdefault("ui", {})["statusLine"] = {
|
|
2240
|
+
"type": "command",
|
|
2241
|
+
"command": tp.hook_command(
|
|
2242
|
+
os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --qwen",
|
|
2243
|
+
"respectUserColors": True,
|
|
2244
|
+
}
|
|
2245
|
+
write_json(path, data)
|
|
2246
|
+
print("%s %s (hooks + ui.statusLine)" % (OK, path))
|
|
2247
|
+
for s in SKILLS:
|
|
2248
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(QWEN, "skills", s)))
|
|
2249
|
+
print("%s %s (always-on contract)" % (OK, write_rules("qwen")))
|
|
2250
|
+
|
|
2251
|
+
|
|
2252
|
+
def droid_hook_groups():
|
|
2253
|
+
"""{event: the group install_droid writes}; `hook_entries` plans from it."""
|
|
2254
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-droid-hook"))
|
|
2255
|
+
out = {}
|
|
2256
|
+
for ev, matcher, timeout in DROID_HOOK_EVENTS:
|
|
2257
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2258
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2259
|
+
out[ev] = group
|
|
2260
|
+
return out
|
|
2261
|
+
|
|
2262
|
+
|
|
2263
|
+
# a JSON string, kept, or a `//` / `/* */` comment, dropped
|
|
2264
|
+
JSONC_TOKENS = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/', re.S)
|
|
2265
|
+
|
|
2266
|
+
|
|
2267
|
+
def read_jsonc(path):
|
|
2268
|
+
"""The object in a JSON-with-comments file (Droid's settings.json), {} when
|
|
2269
|
+
the file is absent, None when it does not parse. A whole rewrite would drop
|
|
2270
|
+
the user's comments, so the only writers are `jsonc_put`/`jsonc_drop`.
|
|
2271
|
+
ponytail: a trailing comma inside a string value is dropped too; the readers
|
|
2272
|
+
only ask for top-level keys."""
|
|
2273
|
+
try:
|
|
2274
|
+
with open(path, encoding="utf-8") as fh:
|
|
2275
|
+
text = fh.read()
|
|
2276
|
+
except FileNotFoundError:
|
|
2277
|
+
return {}
|
|
2278
|
+
except OSError:
|
|
2279
|
+
return None
|
|
2280
|
+
return jsonc_loads(text)
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
def droid_hooks_blocked():
|
|
2284
|
+
"""Why tezgah must not create ~/.factory/hooks.json, or None. Droid reads the
|
|
2285
|
+
`hooks` key of settings.json only while hooks.json is absent, so creating one
|
|
2286
|
+
beside such a key would switch the user's own hooks off."""
|
|
2287
|
+
if os.path.exists(os.path.join(DROID, "hooks.json")):
|
|
2288
|
+
return None
|
|
2289
|
+
path = os.path.join(DROID, "settings.json")
|
|
2290
|
+
data = read_jsonc(path)
|
|
2291
|
+
if data is None:
|
|
2292
|
+
return "%s does not parse, so its `hooks` key cannot be ruled out" % path
|
|
2293
|
+
if "hooks" in data:
|
|
2294
|
+
return ("%s carries a `hooks` key, which Droid ignores once hooks.json "
|
|
2295
|
+
"exists - move those hooks into hooks.json, then re-run --install" % path)
|
|
2296
|
+
return None
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def droid_statusline():
|
|
2300
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --droid"
|
|
2301
|
+
|
|
2302
|
+
|
|
2303
|
+
def jsonc_loads(text):
|
|
2304
|
+
"""read_jsonc's parse of text already read: the object, or None."""
|
|
2305
|
+
text = re.sub(r",(\s*[}\]])", r"\1", JSONC_TOKENS.sub(lambda m: m.group(1) or "", text))
|
|
2306
|
+
try:
|
|
2307
|
+
data = json.loads(text)
|
|
2308
|
+
except ValueError:
|
|
2309
|
+
return None
|
|
2310
|
+
return data if isinstance(data, dict) else None
|
|
2311
|
+
|
|
2312
|
+
|
|
2313
|
+
# the object's opening brace, past any comment or string before it
|
|
2314
|
+
JSONC_OPEN = re.compile(r'("(?:\\.|[^"\\])*")|//[^\n]*|/\*.*?\*/|(\{)', re.S)
|
|
2315
|
+
|
|
2316
|
+
|
|
2317
|
+
def jsonc_put(text, key, value):
|
|
2318
|
+
"""`text` (JSON with comments) with the top-level `key` set to `value` and
|
|
2319
|
+
every comment kept, or None when that cannot be done safely: the file does
|
|
2320
|
+
not parse, the key holds an object or list, or the edit would change
|
|
2321
|
+
anything else. An absent key goes in as one line under the opening brace,
|
|
2322
|
+
the shape `jsonc_drop` takes back out."""
|
|
2323
|
+
data = jsonc_loads(text)
|
|
2324
|
+
if data is None:
|
|
2325
|
+
return None
|
|
2326
|
+
want = dict(data, **{key: value})
|
|
2327
|
+
if key in data:
|
|
2328
|
+
if data[key] == value:
|
|
2329
|
+
return text
|
|
2330
|
+
if isinstance(data[key], (dict, list)):
|
|
2331
|
+
return None
|
|
2332
|
+
new = re.sub(r'("%s"\s*:\s*)%s' % (re.escape(key), re.escape(json.dumps(data[key]))),
|
|
2333
|
+
lambda m: m.group(1) + json.dumps(value), text, count=1)
|
|
2334
|
+
else:
|
|
2335
|
+
brace = next((m for m in JSONC_OPEN.finditer(text) if m.group(2)), None)
|
|
2336
|
+
if brace is None:
|
|
2337
|
+
return None
|
|
2338
|
+
line = "\n %s: %s%s" % (json.dumps(key), json.dumps(value), "," if data else "")
|
|
2339
|
+
new = text[:brace.end()] + line + text[brace.end():]
|
|
2340
|
+
return new if jsonc_loads(new) == want else None
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def jsonc_drop(text, key, value):
|
|
2344
|
+
"""`text` without the one-line `key: value` member `jsonc_put` inserted, or
|
|
2345
|
+
None when that exact line is not there or its removal changes anything else."""
|
|
2346
|
+
data = jsonc_loads(text)
|
|
2347
|
+
if data is None or data.get(key) != value:
|
|
2348
|
+
return None
|
|
2349
|
+
new = re.sub(r'\n[ \t]*%s: %s,?' % (re.escape(json.dumps(key)), re.escape(json.dumps(value))),
|
|
2350
|
+
"", text, count=1)
|
|
2351
|
+
# the line's own comma may now sit before the closing brace (comments may
|
|
2352
|
+
# follow it): take it out, unless the file already carried one there
|
|
2353
|
+
tail = r",(\s*}(?:\s|//[^\n]*|/\*.*?\*/)*)$"
|
|
2354
|
+
if not re.search(tail, text, re.S):
|
|
2355
|
+
new = re.sub(tail, r"\1", new, flags=re.S)
|
|
2356
|
+
rest = dict(data)
|
|
2357
|
+
rest.pop(key)
|
|
2358
|
+
return new if jsonc_loads(new) == rest else None
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
def install_droid():
|
|
2362
|
+
"""Factory Droid: hook groups in `~/.factory/hooks.json`, the status line in
|
|
2363
|
+
`settings.json`, skill links, and the always-on block in `AGENTS.md`.
|
|
2364
|
+
hooks.json may hold the user's own groups, so only a group naming tezgah is
|
|
2365
|
+
replaced. It is not created while settings.json declares hooks
|
|
2366
|
+
(`droid_hooks_blocked`). settings.json is JSON with comments, so the status
|
|
2367
|
+
line and `includeCoAuthoredByDroid: false` (no co-author trailer: the
|
|
2368
|
+
no-attribution rule) are edited in place with every comment kept
|
|
2369
|
+
(`jsonc_put`); a file that cannot be edited safely is left alone and named."""
|
|
2370
|
+
print("\ndroid:")
|
|
2371
|
+
path = os.path.join(DROID, "hooks.json")
|
|
2372
|
+
why = droid_hooks_blocked()
|
|
2373
|
+
if why:
|
|
2374
|
+
print("%s %s not written: %s" % (NO, path, why))
|
|
2375
|
+
else:
|
|
2376
|
+
data = read_config(path, {})
|
|
2377
|
+
if data is not None:
|
|
2378
|
+
for ev, group in droid_hook_groups().items():
|
|
2379
|
+
arr = data.setdefault(ev, [])
|
|
2380
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2381
|
+
arr.append(group)
|
|
2382
|
+
write_json(path, data)
|
|
2383
|
+
print("%s %s (hooks)" % (OK, path))
|
|
2384
|
+
spath = os.path.join(DROID, "settings.json")
|
|
2385
|
+
try:
|
|
2386
|
+
with open(spath, encoding="utf-8") as fh:
|
|
2387
|
+
text = fh.read()
|
|
2388
|
+
except FileNotFoundError:
|
|
2389
|
+
text = "{}\n"
|
|
2390
|
+
except OSError:
|
|
2391
|
+
text = None
|
|
2392
|
+
# the command's stdout is drawn above the input (settings reference)
|
|
2393
|
+
line = {"command": droid_statusline()}
|
|
2394
|
+
try:
|
|
2395
|
+
plain = json.loads(text) if text is not None else None
|
|
2396
|
+
except ValueError:
|
|
2397
|
+
plain = None # comments: edit in place
|
|
2398
|
+
if why:
|
|
2399
|
+
pass # the install stopped at the user's own `hooks` key: settings stays as it is
|
|
2400
|
+
elif isinstance(plain, dict):
|
|
2401
|
+
# plain JSON (an earlier install wrote it): set both keys and rewrite, as before
|
|
2402
|
+
ours = "tezgah-statusline" in json.dumps(plain.get("statusLine") or "")
|
|
2403
|
+
if ours or "statusLine" not in plain:
|
|
2404
|
+
plain["statusLine"] = line
|
|
2405
|
+
plain["includeCoAuthoredByDroid"] = False
|
|
2406
|
+
write_json(spath, plain)
|
|
2407
|
+
print("%s %s (statusLine%s, includeCoAuthoredByDroid false)" % (
|
|
2408
|
+
OK, spath, "" if plain["statusLine"] == line else " left as the user set it"))
|
|
2409
|
+
elif text is not None:
|
|
2410
|
+
old = (jsonc_loads(text) or {}).get("statusLine")
|
|
2411
|
+
stale = old != line and "tezgah-statusline" in json.dumps(old or "")
|
|
2412
|
+
if stale:
|
|
2413
|
+
dropped = jsonc_drop(text, "statusLine", old) # an earlier install's line
|
|
2414
|
+
text, stale = (dropped, False) if dropped is not None else (text, True)
|
|
2415
|
+
# each key on its own: a user's own statusLine never blocks the trailer switch
|
|
2416
|
+
new = jsonc_put(text, "statusLine", line) or text
|
|
2417
|
+
new = jsonc_put(new, "includeCoAuthoredByDroid", False) or new
|
|
2418
|
+
if new != text:
|
|
2419
|
+
write_text(spath, new)
|
|
2420
|
+
done = jsonc_loads(new) or {}
|
|
2421
|
+
print("%s %s (statusLine%s, includeCoAuthoredByDroid %s)" % (
|
|
2422
|
+
OK if done.get("includeCoAuthoredByDroid") is False and not stale else NO, spath,
|
|
2423
|
+
"" if done.get("statusLine") == line else
|
|
2424
|
+
" from an earlier install could not be refreshed: set it with /statusline to `%s`"
|
|
2425
|
+
% line["command"] if stale else " left as the user set it",
|
|
2426
|
+
"false" if done.get("includeCoAuthoredByDroid") is False else "not set: edit by hand"))
|
|
2427
|
+
else:
|
|
2428
|
+
print("%s %s cannot be read: set the status line with /statusline to `%s` and "
|
|
2429
|
+
"`includeCoAuthoredByDroid` to false" % (NO, spath, droid_statusline()))
|
|
2430
|
+
for s in SKILLS:
|
|
2431
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(DROID, "skills", s)))
|
|
2432
|
+
print("%s %s (always-on contract)" % (OK, write_rules("droid")))
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def commandcode_hook_groups():
|
|
2436
|
+
"""{event: the group install_commandcode writes}; `hook_entries` plans from it."""
|
|
2437
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-commandcode-hook"))
|
|
2438
|
+
out = {}
|
|
2439
|
+
for ev, matcher, timeout in COMMANDCODE_HOOK_EVENTS:
|
|
2440
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2441
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2442
|
+
out[ev] = group
|
|
2443
|
+
return out
|
|
2444
|
+
|
|
2445
|
+
|
|
2446
|
+
def install_commandcode():
|
|
2447
|
+
"""Command Code: hook groups in the `hooks` key of
|
|
2448
|
+
`~/.commandcode/settings.json`, skill links, and the always-on block in
|
|
2449
|
+
`AGENTS.md`. settings.json is the user's file, so only a group naming tezgah
|
|
2450
|
+
is replaced and the backup is owner-only (`backup`). The same write turns
|
|
2451
|
+
off the default `Co-authored-by: CommandCodeBot` commit trailer
|
|
2452
|
+
(`attribution.commit` "", docs/settings) unless the user set a value."""
|
|
2453
|
+
print("\ncommandcode:")
|
|
2454
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
2455
|
+
data = read_config(path, {})
|
|
2456
|
+
if data is not None:
|
|
2457
|
+
hooks = data.setdefault("hooks", {})
|
|
2458
|
+
for ev, group in commandcode_hook_groups().items():
|
|
2459
|
+
arr = hooks.setdefault(ev, [])
|
|
2460
|
+
arr[:] = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
2461
|
+
arr.append(group)
|
|
2462
|
+
attr = data.setdefault("attribution", {})
|
|
2463
|
+
if isinstance(attr, dict) and "commit" not in attr:
|
|
2464
|
+
attr["commit"] = ""
|
|
2465
|
+
write_json(path, data)
|
|
2466
|
+
print("%s %s (hooks + attribution.commit)" % (OK, path))
|
|
2467
|
+
if not isinstance(attr, dict) or attr.get("commit") != "":
|
|
2468
|
+
print("%s %s keeps your attribution.commit, so commits still carry a "
|
|
2469
|
+
"co-author trailer" % (NO, path))
|
|
2470
|
+
for s in SKILLS:
|
|
2471
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(COMMANDCODE, "skills", s)))
|
|
2472
|
+
print("%s %s (always-on contract)" % (OK, write_rules("commandcode")))
|
|
2473
|
+
|
|
2474
|
+
|
|
2475
|
+
def copilot_hook_entries():
|
|
2476
|
+
"""{event: the entry install_copilot writes}; `hook_entries` plans from it."""
|
|
2477
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-copilot-hook"))
|
|
2478
|
+
out = {}
|
|
2479
|
+
for ev, matcher, timeout in COPILOT_HOOK_EVENTS:
|
|
2480
|
+
entry = {"type": "command", "timeoutSec": timeout,
|
|
2481
|
+
# a camelCase payload names no event, so the command does
|
|
2482
|
+
"command": cmd + " " + ev if ev[0].islower() else cmd}
|
|
2483
|
+
if matcher:
|
|
2484
|
+
entry["matcher"] = matcher
|
|
2485
|
+
out[ev] = entry
|
|
2486
|
+
return out
|
|
2487
|
+
|
|
2488
|
+
|
|
2489
|
+
def copilot_statusline():
|
|
2490
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --copilot"
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
def install_copilot():
|
|
2494
|
+
"""GitHub Copilot CLI: `$COPILOT_HOME/hooks/tezgah.json`, a hooks file wholly
|
|
2495
|
+
tezgah's (the inline `hooks` key of settings.json is never used: one bad item
|
|
2496
|
+
there rejects every hook in it), the status line and `includeCoAuthoredBy`
|
|
2497
|
+
in settings.json, skill links, and the always-on block in
|
|
2498
|
+
`copilot-instructions.md`. settings.json is JSON with comments, so it is
|
|
2499
|
+
written only while it parses as plain JSON; `includeCoAuthoredBy` (default
|
|
2500
|
+
true, a `Co-authored-by` trailer on the agent's commits) is set false unless
|
|
2501
|
+
the user set it."""
|
|
2502
|
+
print("\ncopilot:")
|
|
2503
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
2504
|
+
write_json(path, {"version": 1, "hooks": {ev: [e] for ev, e in
|
|
2505
|
+
copilot_hook_entries().items()}})
|
|
2506
|
+
print("%s %s (hooks)" % (OK, path))
|
|
2507
|
+
spath = os.path.join(COPILOT, "settings.json")
|
|
2508
|
+
settings = read_json(spath, None) if os.path.exists(spath) else {}
|
|
2509
|
+
if isinstance(settings, dict):
|
|
2510
|
+
settings["statusLine"] = {"type": "command", "command": copilot_statusline()}
|
|
2511
|
+
settings.setdefault("includeCoAuthoredBy", False)
|
|
2512
|
+
write_json(spath, settings)
|
|
2513
|
+
print("%s %s (statusLine + includeCoAuthoredBy)" % (OK, spath))
|
|
2514
|
+
if settings["includeCoAuthoredBy"] is not False:
|
|
2515
|
+
print("%s %s keeps your includeCoAuthoredBy, so commits still carry a "
|
|
2516
|
+
"co-author trailer" % (NO, spath))
|
|
2517
|
+
else:
|
|
2518
|
+
print("%s %s holds comments, which a rewrite would drop: set `statusLine` to "
|
|
2519
|
+
"`%s` and `includeCoAuthoredBy` to false there" % (NO, spath,
|
|
2520
|
+
copilot_statusline()))
|
|
2521
|
+
for s in SKILLS:
|
|
2522
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(COPILOT, "skills", s)))
|
|
2523
|
+
print("%s %s (always-on contract)" % (OK, write_rules("copilot")))
|
|
2524
|
+
|
|
2525
|
+
|
|
2526
|
+
def gemini_hook_groups():
|
|
2527
|
+
"""{event: the group install_gemini writes}; `hook_entries` plans from it."""
|
|
2528
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-gemini-hook"))
|
|
2529
|
+
out = {}
|
|
2530
|
+
for ev, matcher, timeout in GEMINI_HOOK_EVENTS:
|
|
2531
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2532
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2533
|
+
out[ev] = group
|
|
2534
|
+
return out
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def install_gemini():
|
|
2538
|
+
"""Gemini CLI: tezgah's own extension dir, `gemini-extension.json` beside
|
|
2539
|
+
`hooks/hooks.json` and `skills/` links, wholly tezgah's; and the always-on
|
|
2540
|
+
block in `GEMINI.md`. settings.json is never written: it is JSON with
|
|
2541
|
+
comments and holds the user's keys. Gemini's default prompt adds no
|
|
2542
|
+
co-author trailer and no setting names one, so there is nothing to turn off."""
|
|
2543
|
+
print("\ngemini:")
|
|
2544
|
+
write_json(os.path.join(GEMINI_EXT, "gemini-extension.json"),
|
|
2545
|
+
{"name": "tezgah", "version": plugin_version(),
|
|
2546
|
+
"description": "tezgah lifecycle hooks and skills"})
|
|
2547
|
+
path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
|
|
2548
|
+
write_json(path, {"hooks": {ev: [g] for ev, g in gemini_hook_groups().items()}})
|
|
2549
|
+
print("%s %s (extension hooks)" % (OK, path))
|
|
2550
|
+
for s in SKILLS:
|
|
2551
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(GEMINI_EXT, "skills", s)))
|
|
2552
|
+
print("%s %s (always-on contract)" % (OK, write_rules("gemini")))
|
|
2553
|
+
|
|
2554
|
+
|
|
2555
|
+
def antigravity_hook_groups():
|
|
2556
|
+
"""{event: the item install_antigravity writes under hooks.json's `tezgah`
|
|
2557
|
+
name}: a matcher group for a tool event, a bare handler for the others
|
|
2558
|
+
(docs/hooks "Schema and file format"); `hook_entries` plans from it."""
|
|
2559
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-antigravity-hook"))
|
|
2560
|
+
out = {}
|
|
2561
|
+
for ev, matcher, timeout in ANTIGRAVITY_HOOK_EVENTS:
|
|
2562
|
+
handler = {"type": "command", "command": cmd + " " + ev, "timeout": timeout}
|
|
2563
|
+
out[ev] = handler if matcher is None else {"matcher": matcher, "hooks": [handler]}
|
|
2564
|
+
return out
|
|
2565
|
+
|
|
2566
|
+
|
|
2567
|
+
def antigravity_statusline():
|
|
2568
|
+
return tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-statusline")) + " --antigravity"
|
|
2569
|
+
|
|
2570
|
+
|
|
2571
|
+
def install_antigravity():
|
|
2572
|
+
"""Antigravity CLI: tezgah's hook set under the top-level `tezgah` name of
|
|
2573
|
+
`~/.gemini/config/hooks.json`, beside the user's other names (hooks in
|
|
2574
|
+
antigravity-cli/settings.json are never loaded, docs/hooks); the Antigravity
|
|
2575
|
+
App and IDE read that file too. The status line in
|
|
2576
|
+
`~/.gemini/antigravity-cli/settings.json`, written only while it is plain
|
|
2577
|
+
JSON; skill links in `antigravity-cli/skills`; the always-on block in the
|
|
2578
|
+
`~/.gemini/GEMINI.md` Gemini CLI shares. Antigravity adds no co-author
|
|
2579
|
+
trailer of its own and no setting names one, so there is nothing to turn off."""
|
|
2580
|
+
print("\nantigravity:")
|
|
2581
|
+
data = read_config(AGY_HOOKS, {})
|
|
2582
|
+
if data is not None:
|
|
2583
|
+
data["tezgah"] = {ev: [g] for ev, g in antigravity_hook_groups().items()}
|
|
2584
|
+
write_json(AGY_HOOKS, data)
|
|
2585
|
+
print("%s %s (hooks; the Antigravity App and IDE load them too)" % (OK, AGY_HOOKS))
|
|
2586
|
+
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
2587
|
+
settings = read_json(spath, None) if os.path.exists(spath) else {}
|
|
2588
|
+
if isinstance(settings, dict):
|
|
2589
|
+
settings["statusLine"] = {"type": "command", "command": antigravity_statusline()}
|
|
2590
|
+
write_json(spath, settings)
|
|
2591
|
+
print("%s %s (statusLine)" % (OK, spath))
|
|
2592
|
+
else:
|
|
2593
|
+
print("%s %s is not plain JSON, which a rewrite would lose: set `statusLine` "
|
|
2594
|
+
"to {\"type\": \"command\", \"command\": \"%s\"} there"
|
|
2595
|
+
% (NO, spath, antigravity_statusline()))
|
|
2596
|
+
for s in SKILLS:
|
|
2597
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(ANTIGRAVITY, "skills", s)))
|
|
2598
|
+
print("%s %s (always-on contract)" % (OK, write_rules("antigravity")))
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
def goose_hook_groups():
|
|
2602
|
+
"""{event: the group install_goose writes}; `hook_entries` plans from it."""
|
|
2603
|
+
cmd = tp.hook_command(os.path.join(tp.BIN_DIR, "tezgah-goose-hook"))
|
|
2604
|
+
out = {}
|
|
2605
|
+
for ev, matcher, timeout in GOOSE_HOOK_EVENTS:
|
|
2606
|
+
group = {"matcher": matcher} if matcher else {}
|
|
2607
|
+
group["hooks"] = [{"type": "command", "command": cmd, "timeout": timeout}]
|
|
2608
|
+
out[ev] = group
|
|
2609
|
+
return out
|
|
2610
|
+
|
|
2611
|
+
|
|
2612
|
+
def install_goose():
|
|
2613
|
+
"""Goose: tezgah's own plugin dir, `~/.agents/plugins/tezgah/`, wholly
|
|
2614
|
+
tezgah's - `plugin.json` beside `hooks/hooks.json` and `skills/` links (Goose
|
|
2615
|
+
loads them as `tezgah:<skill>`) - and the always-on block in
|
|
2616
|
+
`~/.config/goose/.goosehints`. The user's other `~/.agents` content is never
|
|
2617
|
+
touched. `on_failure` stays at its default `allow`, so a hook fault lets the
|
|
2618
|
+
call through. Neither Goose's docs nor its system prompt add a co-author
|
|
2619
|
+
trailer or name a setting for one, so there is nothing to turn off."""
|
|
2620
|
+
print("\ngoose:")
|
|
2621
|
+
write_json(os.path.join(GOOSE_PLUGIN, "plugin.json"),
|
|
2622
|
+
{"name": "tezgah", "version": plugin_version(),
|
|
2623
|
+
"description": "tezgah lifecycle hooks and skills"})
|
|
2624
|
+
path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
|
|
2625
|
+
write_json(path, {"hooks": {ev: [g] for ev, g in goose_hook_groups().items()}})
|
|
2626
|
+
print("%s %s (plugin hooks)" % (OK, path))
|
|
2627
|
+
for s in SKILLS:
|
|
2628
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(GOOSE_PLUGIN, "skills", s)))
|
|
2629
|
+
print("%s %s (always-on contract)" % (OK, write_rules("goose")))
|
|
2630
|
+
|
|
2631
|
+
|
|
2632
|
+
def amp_settings():
|
|
2633
|
+
"""(path, settings dict or None): Amp's user settings.json as tezgah may
|
|
2634
|
+
rewrite it - absent reads as {}, and None stands for a file tezgah must not
|
|
2635
|
+
touch: one that is not plain JSON, or a settings.jsonc beside it (which of
|
|
2636
|
+
the two wins is undocumented)."""
|
|
2637
|
+
path = os.path.join(AMP, "settings.json")
|
|
2638
|
+
if os.path.exists(os.path.join(AMP, "settings.jsonc")):
|
|
2639
|
+
return path, None
|
|
2640
|
+
return path, read_json(path, None) if os.path.exists(path) else {}
|
|
2641
|
+
|
|
2642
|
+
|
|
2643
|
+
def install_amp():
|
|
2644
|
+
"""Amp: one system plugin file, `$XDG_CONFIG_HOME/amp/plugins/tezgah.ts`,
|
|
2645
|
+
rendered from `hosts/amp/tezgah-plugin.ts.in` (Amp loads every .ts there,
|
|
2646
|
+
nothing registers it); skill links in `~/.config/amp/skills`; the always-on
|
|
2647
|
+
block in `~/.config/amp/AGENTS.md`; and Amp's two commit trailers set off
|
|
2648
|
+
in settings.json unless the user set them."""
|
|
2649
|
+
print("\namp:")
|
|
2650
|
+
plugin = ta.amp_plugin(HERE)
|
|
2651
|
+
if not plugin:
|
|
2652
|
+
print("%s %s: this tree has no hosts/amp/tezgah-plugin.ts.in to render"
|
|
2653
|
+
% (NO, AMP_PLUGIN))
|
|
2654
|
+
elif write_if_changed(AMP_PLUGIN, plugin):
|
|
2655
|
+
print("%s %s (plugin: gate + ledger + Stop rule)" % (OK, AMP_PLUGIN))
|
|
2656
|
+
print("%s amp: run `plugins: reload` in a running Amp to load it" % OK)
|
|
2657
|
+
spath, settings = amp_settings()
|
|
2658
|
+
if isinstance(settings, dict):
|
|
2659
|
+
for key in AMP_ATTRIBUTION:
|
|
2660
|
+
settings.setdefault(key, False)
|
|
2661
|
+
write_json(spath, settings)
|
|
2662
|
+
print("%s %s (commit trailers off)" % (OK, spath))
|
|
2663
|
+
for key in AMP_ATTRIBUTION:
|
|
2664
|
+
if settings[key] is not False:
|
|
2665
|
+
print("%s %s keeps your %s, so the agent's commits still carry "
|
|
2666
|
+
"that trailer" % (NO, spath, key))
|
|
2667
|
+
else:
|
|
2668
|
+
print("%s %s is not plain JSON (or settings.jsonc exists): set %s to false "
|
|
2669
|
+
"there" % (NO, spath, " and ".join("`%s`" % k for k in AMP_ATTRIBUTION)))
|
|
2670
|
+
for s in SKILLS:
|
|
2671
|
+
print(link(os.path.join(HERE, "skills", s), os.path.join(AMP, "skills", s)))
|
|
2672
|
+
print("%s %s (always-on contract)" % (OK, write_rules("amp")))
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
|
|
2090
2676
|
def dsh_app_mcp_rows():
|
|
2091
2677
|
"""One dsh-mcp-client row per MCP server tezgah writes into the block.
|
|
2092
2678
|
|
|
@@ -2471,7 +3057,32 @@ CLAUDE_RULES = os.path.join(CLAUDE, "CLAUDE.md")
|
|
|
2471
3057
|
# and where that file is. A lambda per row, because codex_rules() answers from
|
|
2472
3058
|
# the disk (AGENTS.override.md wins when it exists).
|
|
2473
3059
|
STATIC_RULES = {"claude": lambda: CLAUDE_RULES, "codex": codex_rules,
|
|
2474
|
-
"omp": lambda: os.path.join(OMP_AGENT, "RULES.md")
|
|
3060
|
+
"omp": lambda: os.path.join(OMP_AGENT, "RULES.md"),
|
|
3061
|
+
"qwen": lambda: os.path.join(QWEN, "QWEN.md"),
|
|
3062
|
+
"droid": lambda: os.path.join(DROID, "AGENTS.md"),
|
|
3063
|
+
"commandcode": lambda: os.path.join(COMMANDCODE, "AGENTS.md"),
|
|
3064
|
+
"copilot": lambda: os.path.join(COPILOT, "copilot-instructions.md"),
|
|
3065
|
+
"gemini": lambda: os.path.join(GEMINI, "GEMINI.md"),
|
|
3066
|
+
"antigravity": lambda: os.path.join(os.path.dirname(ANTIGRAVITY), "GEMINI.md"),
|
|
3067
|
+
"goose": lambda: os.path.join(GOOSE, ".goosehints"),
|
|
3068
|
+
"amp": lambda: os.path.join(AMP, "AGENTS.md")}
|
|
3069
|
+
# Gemini CLI and Antigravity both load ~/.gemini/GEMINI.md, so they share ONE
|
|
3070
|
+
# managed block: an uninstall strips it only while no other host of this tuple
|
|
3071
|
+
# that reads the same file stays armed (`gemini_md_kept`; GEMINI_CLI_HOME moves
|
|
3072
|
+
# Gemini's copy away), and each host's uninstall asks the same question, so the
|
|
3073
|
+
# last one out takes the block.
|
|
3074
|
+
GEMINI_MD_HOSTS = ("gemini", "antigravity")
|
|
3075
|
+
# the hosts the running uninstall takes out (`uninstall` sets it)
|
|
3076
|
+
UNINSTALLING = ()
|
|
3077
|
+
|
|
3078
|
+
|
|
3079
|
+
def gemini_md_kept(host):
|
|
3080
|
+
"""True while another GEMINI_MD_HOSTS host this uninstall does not touch is
|
|
3081
|
+
armed and reads `host`'s GEMINI.md."""
|
|
3082
|
+
armed = tp.config().get("hosts") or []
|
|
3083
|
+
path = STATIC_RULES[host]()
|
|
3084
|
+
return any(h in armed and h not in UNINSTALLING and STATIC_RULES[h]() == path
|
|
3085
|
+
for h in GEMINI_MD_HOSTS)
|
|
2475
3086
|
|
|
2476
3087
|
|
|
2477
3088
|
def rules_block(host):
|
|
@@ -2586,7 +3197,11 @@ def install_omp():
|
|
|
2586
3197
|
|
|
2587
3198
|
INSTALLERS = {"claude": install_claude, "codex": install_codex,
|
|
2588
3199
|
"opencode": install_opencode, "cursor": install_cursor,
|
|
2589
|
-
"dsh": install_dsh, "omp": install_omp
|
|
3200
|
+
"dsh": install_dsh, "omp": install_omp, "qwen": install_qwen,
|
|
3201
|
+
"droid": install_droid, "commandcode": install_commandcode,
|
|
3202
|
+
"copilot": install_copilot, "gemini": install_gemini,
|
|
3203
|
+
"antigravity": install_antigravity, "goose": install_goose,
|
|
3204
|
+
"amp": install_amp}
|
|
2590
3205
|
|
|
2591
3206
|
|
|
2592
3207
|
def install_openresearch(hosts):
|
|
@@ -3466,6 +4081,67 @@ def verify_uninstall(hosts, full):
|
|
|
3466
4081
|
claim("omp skills unlinked",
|
|
3467
4082
|
[os.path.join(OMP_AGENT, "skills", s) for s in SKILLS
|
|
3468
4083
|
if os.path.lexists(os.path.join(OMP_AGENT, "skills", s))])
|
|
4084
|
+
if "qwen" in hosts:
|
|
4085
|
+
clean("qwen settings.json unwired", [os.path.join(QWEN, "settings.json")])
|
|
4086
|
+
clean("qwen global QWEN.md block gone", [os.path.join(QWEN, "QWEN.md")],
|
|
4087
|
+
needle="<!-- tezgah:start")
|
|
4088
|
+
claim("qwen skills unlinked",
|
|
4089
|
+
[os.path.join(QWEN, "skills", s) for s in SKILLS
|
|
4090
|
+
if os.path.lexists(os.path.join(QWEN, "skills", s))])
|
|
4091
|
+
if "droid" in hosts:
|
|
4092
|
+
clean("droid hooks.json unwired", [os.path.join(DROID, "hooks.json")])
|
|
4093
|
+
clean("droid settings.json statusLine gone", [os.path.join(DROID, "settings.json")],
|
|
4094
|
+
needle="tezgah-statusline")
|
|
4095
|
+
clean("droid global AGENTS.md block gone", [os.path.join(DROID, "AGENTS.md")],
|
|
4096
|
+
needle="<!-- tezgah:start")
|
|
4097
|
+
claim("droid skills unlinked",
|
|
4098
|
+
[os.path.join(DROID, "skills", s) for s in SKILLS
|
|
4099
|
+
if os.path.lexists(os.path.join(DROID, "skills", s))])
|
|
4100
|
+
if "commandcode" in hosts:
|
|
4101
|
+
clean("commandcode settings.json unwired",
|
|
4102
|
+
[os.path.join(COMMANDCODE, "settings.json")])
|
|
4103
|
+
clean("commandcode global AGENTS.md block gone",
|
|
4104
|
+
[os.path.join(COMMANDCODE, "AGENTS.md")], needle="<!-- tezgah:start")
|
|
4105
|
+
claim("commandcode skills unlinked",
|
|
4106
|
+
[os.path.join(COMMANDCODE, "skills", s) for s in SKILLS
|
|
4107
|
+
if os.path.lexists(os.path.join(COMMANDCODE, "skills", s))])
|
|
4108
|
+
if "copilot" in hosts:
|
|
4109
|
+
claim("copilot hooks/tezgah.json gone",
|
|
4110
|
+
[os.path.join(COPILOT, "hooks", "tezgah.json")])
|
|
4111
|
+
clean("copilot settings.json statusLine gone",
|
|
4112
|
+
[os.path.join(COPILOT, "settings.json")], needle="tezgah-statusline")
|
|
4113
|
+
clean("copilot copilot-instructions.md block gone",
|
|
4114
|
+
[os.path.join(COPILOT, "copilot-instructions.md")], needle="<!-- tezgah:start")
|
|
4115
|
+
claim("copilot skills unlinked",
|
|
4116
|
+
[os.path.join(COPILOT, "skills", s) for s in SKILLS
|
|
4117
|
+
if os.path.lexists(os.path.join(COPILOT, "skills", s))])
|
|
4118
|
+
if "gemini" in hosts:
|
|
4119
|
+
claim("gemini extension dir gone", [GEMINI_EXT])
|
|
4120
|
+
if not gemini_md_kept("gemini"):
|
|
4121
|
+
clean("gemini GEMINI.md block gone", [os.path.join(GEMINI, "GEMINI.md")],
|
|
4122
|
+
needle="<!-- tezgah:start")
|
|
4123
|
+
if "antigravity" in hosts:
|
|
4124
|
+
clean("antigravity config/hooks.json unwired", [AGY_HOOKS],
|
|
4125
|
+
needle="tezgah-antigravity-hook")
|
|
4126
|
+
clean("antigravity settings.json statusLine gone",
|
|
4127
|
+
[os.path.join(ANTIGRAVITY, "settings.json")], needle="tezgah-statusline")
|
|
4128
|
+
claim("antigravity skills unlinked",
|
|
4129
|
+
[os.path.join(ANTIGRAVITY, "skills", s) for s in SKILLS
|
|
4130
|
+
if os.path.lexists(os.path.join(ANTIGRAVITY, "skills", s))])
|
|
4131
|
+
if not gemini_md_kept("antigravity"):
|
|
4132
|
+
clean("antigravity GEMINI.md block gone", [STATIC_RULES["antigravity"]()],
|
|
4133
|
+
needle="<!-- tezgah:start")
|
|
4134
|
+
if "goose" in hosts:
|
|
4135
|
+
claim("goose plugin dir gone", [GOOSE_PLUGIN])
|
|
4136
|
+
clean("goose .goosehints block gone", [STATIC_RULES["goose"]()],
|
|
4137
|
+
needle="<!-- tezgah:start")
|
|
4138
|
+
if "amp" in hosts:
|
|
4139
|
+
claim("amp plugin gone", [AMP_PLUGIN] if amp_plugin_ours() else [])
|
|
4140
|
+
claim("amp skills unlinked",
|
|
4141
|
+
[os.path.join(AMP, "skills", s) for s in SKILLS
|
|
4142
|
+
if os.path.lexists(os.path.join(AMP, "skills", s))])
|
|
4143
|
+
clean("amp AGENTS.md block gone", [STATIC_RULES["amp"]()],
|
|
4144
|
+
needle="<!-- tezgah:start")
|
|
3469
4145
|
if "claude" in hosts:
|
|
3470
4146
|
clean("claude settings.json unwired",
|
|
3471
4147
|
[os.path.join(CLAUDE, "settings.json")])
|
|
@@ -3681,6 +4357,213 @@ def uninstall_cursor():
|
|
|
3681
4357
|
print("%s removed tezgah statusLine from %s" % (OK, cc))
|
|
3682
4358
|
|
|
3683
4359
|
|
|
4360
|
+
def uninstall_qwen():
|
|
4361
|
+
print("\nqwen:")
|
|
4362
|
+
for s in SKILLS:
|
|
4363
|
+
unlink_tezgah(os.path.join(QWEN, "skills", s))
|
|
4364
|
+
path = os.path.join(QWEN, "settings.json")
|
|
4365
|
+
drop_managed_hook_entries(path)
|
|
4366
|
+
cfg = read_json(path, None)
|
|
4367
|
+
ui = cfg.get("ui") if isinstance(cfg, dict) else None
|
|
4368
|
+
if isinstance(ui, dict) and "tezgah-statusline" in json.dumps(ui.get("statusLine") or {}):
|
|
4369
|
+
ui.pop("statusLine")
|
|
4370
|
+
if not ui:
|
|
4371
|
+
cfg.pop("ui")
|
|
4372
|
+
write_json(path, cfg)
|
|
4373
|
+
print("%s removed tezgah statusLine from %s" % (OK, path))
|
|
4374
|
+
drop_if_empty(path)
|
|
4375
|
+
strip_managed_md(os.path.join(QWEN, "QWEN.md"))
|
|
4376
|
+
|
|
4377
|
+
|
|
4378
|
+
def drop_if_empty(path):
|
|
4379
|
+
"""Remove a host settings file uninstall emptied: tezgah created it to hold
|
|
4380
|
+
its hooks or status line, and a `{}` the user never had is left over."""
|
|
4381
|
+
if read_json(path, None) == {}:
|
|
4382
|
+
backup(path)
|
|
4383
|
+
os.remove(path)
|
|
4384
|
+
print("%s removed %s (empty once tezgah's keys left)" % (OK, path))
|
|
4385
|
+
|
|
4386
|
+
|
|
4387
|
+
def uninstall_droid():
|
|
4388
|
+
print("\ndroid:")
|
|
4389
|
+
for s in SKILLS:
|
|
4390
|
+
unlink_tezgah(os.path.join(DROID, "skills", s))
|
|
4391
|
+
path = os.path.join(DROID, "hooks.json")
|
|
4392
|
+
data = read_json(path, None)
|
|
4393
|
+
if isinstance(data, dict):
|
|
4394
|
+
kept = {}
|
|
4395
|
+
for ev, arr in data.items():
|
|
4396
|
+
if isinstance(arr, list):
|
|
4397
|
+
arr = [e for e in arr if "tezgah" not in json.dumps(e)]
|
|
4398
|
+
if not arr:
|
|
4399
|
+
continue
|
|
4400
|
+
kept[ev] = arr
|
|
4401
|
+
if kept == data:
|
|
4402
|
+
print("%s no tezgah hook entries in %s" % (OK, path))
|
|
4403
|
+
elif kept:
|
|
4404
|
+
write_json(path, kept)
|
|
4405
|
+
print("%s removed tezgah hook entries from %s" % (OK, path))
|
|
4406
|
+
else:
|
|
4407
|
+
# an empty hooks.json would still hide the `hooks` key of settings.json
|
|
4408
|
+
backup(path)
|
|
4409
|
+
os.remove(path)
|
|
4410
|
+
print("%s removed %s (it held only tezgah's hooks)" % (OK, path))
|
|
4411
|
+
spath = os.path.join(DROID, "settings.json")
|
|
4412
|
+
cfg = read_json(spath, None)
|
|
4413
|
+
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4414
|
+
cfg.pop("statusLine")
|
|
4415
|
+
write_json(spath, cfg)
|
|
4416
|
+
print("%s removed tezgah statusLine from %s" % (OK, spath))
|
|
4417
|
+
elif cfg is None and os.path.exists(spath):
|
|
4418
|
+
# JSON with comments: take out the one line install_droid put in, keep
|
|
4419
|
+
# the comments; `includeCoAuthoredByDroid` stays false (the
|
|
4420
|
+
# no-attribution rule is the user's, not tezgah's to undo)
|
|
4421
|
+
with open(spath, encoding="utf-8") as fh:
|
|
4422
|
+
text = fh.read()
|
|
4423
|
+
old = (jsonc_loads(text) or {}).get("statusLine")
|
|
4424
|
+
new = jsonc_drop(text, "statusLine", old) if "tezgah-statusline" in json.dumps(old or "") else None
|
|
4425
|
+
if new is not None:
|
|
4426
|
+
write_text(spath, new)
|
|
4427
|
+
print("%s removed tezgah statusLine from %s" % (OK, spath))
|
|
4428
|
+
drop_if_empty(spath)
|
|
4429
|
+
strip_managed_md(os.path.join(DROID, "AGENTS.md"))
|
|
4430
|
+
|
|
4431
|
+
|
|
4432
|
+
def uninstall_commandcode():
|
|
4433
|
+
print("\ncommandcode:")
|
|
4434
|
+
for s in SKILLS:
|
|
4435
|
+
unlink_tezgah(os.path.join(COMMANDCODE, "skills", s))
|
|
4436
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
4437
|
+
drop_managed_hook_entries(path)
|
|
4438
|
+
cfg = read_json(path, None)
|
|
4439
|
+
attr = cfg.get("attribution") if isinstance(cfg, dict) else None
|
|
4440
|
+
# the value install wrote, beside the user's other attribution keys or
|
|
4441
|
+
# alone; as with Claude's, a user's own identical value cannot be told
|
|
4442
|
+
# apart from it, and dropping it only restores Command Code's default
|
|
4443
|
+
if isinstance(attr, dict) and attr.get("commit") == "":
|
|
4444
|
+
attr.pop("commit")
|
|
4445
|
+
if not attr:
|
|
4446
|
+
cfg.pop("attribution")
|
|
4447
|
+
write_json(path, cfg)
|
|
4448
|
+
print("%s removed tezgah's attribution.commit from %s" % (OK, path))
|
|
4449
|
+
drop_if_empty(path)
|
|
4450
|
+
strip_managed_md(os.path.join(COMMANDCODE, "AGENTS.md"))
|
|
4451
|
+
|
|
4452
|
+
|
|
4453
|
+
def uninstall_copilot():
|
|
4454
|
+
print("\ncopilot:")
|
|
4455
|
+
for s in SKILLS:
|
|
4456
|
+
unlink_tezgah(os.path.join(COPILOT, "skills", s))
|
|
4457
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
4458
|
+
if os.path.exists(path):
|
|
4459
|
+
backup(path)
|
|
4460
|
+
os.remove(path)
|
|
4461
|
+
print("%s removed %s" % (OK, path))
|
|
4462
|
+
spath = os.path.join(COPILOT, "settings.json")
|
|
4463
|
+
cfg = read_json(spath, None)
|
|
4464
|
+
if isinstance(cfg, dict):
|
|
4465
|
+
if "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4466
|
+
cfg.pop("statusLine")
|
|
4467
|
+
# the value install wrote; a user's own false cannot be told apart from it
|
|
4468
|
+
if cfg.get("includeCoAuthoredBy") is False:
|
|
4469
|
+
cfg.pop("includeCoAuthoredBy")
|
|
4470
|
+
if write_json(spath, cfg):
|
|
4471
|
+
print("%s removed tezgah's statusLine and includeCoAuthoredBy from %s"
|
|
4472
|
+
% (OK, spath))
|
|
4473
|
+
drop_if_empty(spath)
|
|
4474
|
+
strip_managed_md(os.path.join(COPILOT, "copilot-instructions.md"))
|
|
4475
|
+
|
|
4476
|
+
|
|
4477
|
+
def uninstall_gemini():
|
|
4478
|
+
"""Delete tezgah's extension dir (its skills are links, unlinked, never
|
|
4479
|
+
followed) and the GEMINI.md block unless Antigravity still reads it."""
|
|
4480
|
+
print("\ngemini:")
|
|
4481
|
+
if os.path.lexists(GEMINI_EXT):
|
|
4482
|
+
for name in ("gemini-extension.json", os.path.join("hooks", "hooks.json")):
|
|
4483
|
+
backup(os.path.join(GEMINI_EXT, name))
|
|
4484
|
+
shutil.rmtree(GEMINI_EXT)
|
|
4485
|
+
print("%s removed %s" % (OK, GEMINI_EXT))
|
|
4486
|
+
if gemini_md_kept("gemini"):
|
|
4487
|
+
print("%s %s keeps the tezgah block: another armed host reads it"
|
|
4488
|
+
% (OK, os.path.join(GEMINI, "GEMINI.md")))
|
|
4489
|
+
else:
|
|
4490
|
+
strip_managed_md(os.path.join(GEMINI, "GEMINI.md"))
|
|
4491
|
+
|
|
4492
|
+
|
|
4493
|
+
def uninstall_antigravity():
|
|
4494
|
+
"""Take the `tezgah` name out of ~/.gemini/config/hooks.json (the user's
|
|
4495
|
+
other names stay), the statusLine, the skill links, and the GEMINI.md block
|
|
4496
|
+
unless Gemini CLI still reads it."""
|
|
4497
|
+
print("\nantigravity:")
|
|
4498
|
+
for s in SKILLS:
|
|
4499
|
+
unlink_tezgah(os.path.join(ANTIGRAVITY, "skills", s))
|
|
4500
|
+
data = read_json(AGY_HOOKS, None)
|
|
4501
|
+
if isinstance(data, dict) and "tezgah" in data:
|
|
4502
|
+
data.pop("tezgah")
|
|
4503
|
+
write_json(AGY_HOOKS, data)
|
|
4504
|
+
print("%s removed tezgah's hooks from %s" % (OK, AGY_HOOKS))
|
|
4505
|
+
drop_if_empty(AGY_HOOKS)
|
|
4506
|
+
spath = os.path.join(ANTIGRAVITY, "settings.json")
|
|
4507
|
+
cfg = read_json(spath, None)
|
|
4508
|
+
if isinstance(cfg, dict) and "tezgah-statusline" in json.dumps(cfg.get("statusLine") or {}):
|
|
4509
|
+
cfg.pop("statusLine")
|
|
4510
|
+
write_json(spath, cfg)
|
|
4511
|
+
print("%s removed tezgah's statusLine from %s" % (OK, spath))
|
|
4512
|
+
drop_if_empty(spath)
|
|
4513
|
+
rules = STATIC_RULES["antigravity"]()
|
|
4514
|
+
if gemini_md_kept("antigravity"):
|
|
4515
|
+
print("%s %s keeps the tezgah block: another armed host reads it" % (OK, rules))
|
|
4516
|
+
else:
|
|
4517
|
+
strip_managed_md(rules)
|
|
4518
|
+
|
|
4519
|
+
|
|
4520
|
+
def uninstall_goose():
|
|
4521
|
+
"""Delete tezgah's plugin dir (its skills are links, unlinked, never
|
|
4522
|
+
followed) and the .goosehints block; the rest of ~/.agents is the user's."""
|
|
4523
|
+
print("\ngoose:")
|
|
4524
|
+
if os.path.lexists(GOOSE_PLUGIN):
|
|
4525
|
+
for name in ("plugin.json", os.path.join("hooks", "hooks.json")):
|
|
4526
|
+
backup(os.path.join(GOOSE_PLUGIN, name))
|
|
4527
|
+
shutil.rmtree(GOOSE_PLUGIN)
|
|
4528
|
+
print("%s removed %s" % (OK, GOOSE_PLUGIN))
|
|
4529
|
+
strip_managed_md(STATIC_RULES["goose"]())
|
|
4530
|
+
|
|
4531
|
+
|
|
4532
|
+
def amp_plugin_ours():
|
|
4533
|
+
"""True when the file at AMP_PLUGIN is tezgah's: a fresh render, or one an
|
|
4534
|
+
older tree rendered (it starts with AMP_HEADER). A user's own plugin of
|
|
4535
|
+
that name is never removed."""
|
|
4536
|
+
try:
|
|
4537
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
4538
|
+
text = fh.read()
|
|
4539
|
+
except OSError:
|
|
4540
|
+
return False
|
|
4541
|
+
return text == ta.amp_plugin(HERE) or text.startswith(ta.AMP_HEADER)
|
|
4542
|
+
|
|
4543
|
+
|
|
4544
|
+
def uninstall_amp():
|
|
4545
|
+
"""Delete tezgah's plugin file (only when it is tezgah's), the skill links,
|
|
4546
|
+
the AGENTS.md block, and each commit-trailer key while it is still false."""
|
|
4547
|
+
print("\namp:")
|
|
4548
|
+
if amp_plugin_ours():
|
|
4549
|
+
backup(AMP_PLUGIN)
|
|
4550
|
+
os.remove(AMP_PLUGIN)
|
|
4551
|
+
print("%s removed %s" % (OK, AMP_PLUGIN))
|
|
4552
|
+
elif os.path.lexists(AMP_PLUGIN):
|
|
4553
|
+
print("%s %s is not tezgah's render: left in place" % (NO, AMP_PLUGIN))
|
|
4554
|
+
for s in SKILLS:
|
|
4555
|
+
unlink_tezgah(os.path.join(AMP, "skills", s))
|
|
4556
|
+
spath, settings = amp_settings()
|
|
4557
|
+
if isinstance(settings, dict) and any(settings.get(k) is False for k in AMP_ATTRIBUTION):
|
|
4558
|
+
for key in AMP_ATTRIBUTION:
|
|
4559
|
+
if settings.get(key) is False:
|
|
4560
|
+
settings.pop(key)
|
|
4561
|
+
write_json(spath, settings)
|
|
4562
|
+
print("%s removed tezgah's commit-trailer keys from %s" % (OK, spath))
|
|
4563
|
+
drop_if_empty(spath)
|
|
4564
|
+
strip_managed_md(STATIC_RULES["amp"]())
|
|
4565
|
+
|
|
4566
|
+
|
|
3684
4567
|
def uninstall_dsh():
|
|
3685
4568
|
print("\ndsh:")
|
|
3686
4569
|
for s in SKILLS:
|
|
@@ -3733,7 +4616,11 @@ def uninstall_omp():
|
|
|
3733
4616
|
|
|
3734
4617
|
UNINSTALLERS = {"claude": uninstall_claude, "codex": uninstall_codex,
|
|
3735
4618
|
"opencode": uninstall_opencode, "cursor": uninstall_cursor,
|
|
3736
|
-
"dsh": uninstall_dsh, "omp": uninstall_omp
|
|
4619
|
+
"dsh": uninstall_dsh, "omp": uninstall_omp, "qwen": uninstall_qwen,
|
|
4620
|
+
"droid": uninstall_droid, "commandcode": uninstall_commandcode,
|
|
4621
|
+
"copilot": uninstall_copilot, "gemini": uninstall_gemini,
|
|
4622
|
+
"antigravity": uninstall_antigravity, "goose": uninstall_goose,
|
|
4623
|
+
"amp": uninstall_amp}
|
|
3737
4624
|
|
|
3738
4625
|
|
|
3739
4626
|
def uninstall(hosts, dry_run=False):
|
|
@@ -3755,6 +4642,7 @@ def uninstall(hosts, dry_run=False):
|
|
|
3755
4642
|
repository's `.tezgah/` research state. A partial run (some armed hosts
|
|
3756
4643
|
stay) also keeps config.json and the install tree, because the hosts still
|
|
3757
4644
|
armed read both at runtime."""
|
|
4645
|
+
global UNINSTALLING
|
|
3758
4646
|
armed = tp.config().get("hosts") or []
|
|
3759
4647
|
full = not armed or set(hosts) >= set(armed)
|
|
3760
4648
|
if dry_run:
|
|
@@ -3786,6 +4674,7 @@ def uninstall(hosts, dry_run=False):
|
|
|
3786
4674
|
print("%s gate stays armed for the hosts this run does not touch" % OK)
|
|
3787
4675
|
print("uninstalling for: %s (%s run)\n"
|
|
3788
4676
|
% (", ".join(hosts) or "none detected", "full" if full else "partial"))
|
|
4677
|
+
UNINSTALLING = tuple(hosts)
|
|
3789
4678
|
uninstall_common()
|
|
3790
4679
|
for h in hosts:
|
|
3791
4680
|
UNINSTALLERS[h]()
|
|
@@ -4128,12 +5017,13 @@ def context_budget_report():
|
|
|
4128
5017
|
|
|
4129
5018
|
LIVE_TIMEOUT = 20
|
|
4130
5019
|
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
|
|
5020
|
+
# The JS half: loads the omp extension or the Amp plugin
|
|
5021
|
+
# (`--experimental-strip-types`, the .ts as their bun would) or the opencode
|
|
5022
|
+
# plugin (an ES module, as opencode imports it), registers its handlers against
|
|
5023
|
+
# inert host objects and fires one handler: the tool-result one, or with a
|
|
5024
|
+
# command the tool-call one, printing what it answered (omp, Amp) or what it
|
|
5025
|
+
# threw (opencode) as JSON. argv: host, module path, root, session id, command
|
|
5026
|
+
# ("" for the tool result).
|
|
4137
5027
|
LIVE_DRIVER = r"""
|
|
4138
5028
|
const [host, mod, root, sid, cmd] = process.argv.slice(1);
|
|
4139
5029
|
const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
|
|
@@ -4154,6 +5044,25 @@ const noop = new Proxy(function () {}, { get: () => noop, apply: () => noop });
|
|
|
4154
5044
|
}
|
|
4155
5045
|
await handlers.tool_result({ toolName: "bash", input: { command: "true" },
|
|
4156
5046
|
isError: false, content: "ok" }, ctx);
|
|
5047
|
+
} else if (host === "amp") {
|
|
5048
|
+
// Amp's registry, its two helpers the plugin reads, and a thread context;
|
|
5049
|
+
// no status item (`experimental` absent), so no status spawn either
|
|
5050
|
+
const handlers = {};
|
|
5051
|
+
m.default(new Proxy({ on: (n, fn) => { handlers[n] = fn; }, experimental: undefined,
|
|
5052
|
+
system: { workspaceRoot: root },
|
|
5053
|
+
helpers: { filePathFromURI: (u) => String(u),
|
|
5054
|
+
shellCommandFromToolCall: (e) => ({ command: e.input.cmd }) } },
|
|
5055
|
+
{ get: (t, k) => (k in t ? t[k] : noop) }));
|
|
5056
|
+
const want = cmd ? "tool.call" : "tool.result";
|
|
5057
|
+
if (!handlers[want]) throw new Error("the plugin registered no " + want + " handler");
|
|
5058
|
+
const ctx = { thread: { id: sid, parentThreadID: async () => null }, ui: noop };
|
|
5059
|
+
const event = { tool: "Bash", input: { cmd: cmd || "true" }, toolUseID: "live",
|
|
5060
|
+
thread: { id: sid } };
|
|
5061
|
+
if (cmd) {
|
|
5062
|
+
process.stdout.write(JSON.stringify((await handlers[want](event, ctx)) || {}));
|
|
5063
|
+
return;
|
|
5064
|
+
}
|
|
5065
|
+
await handlers[want](Object.assign(event, { status: "done", output: "ok" }), ctx);
|
|
4157
5066
|
} else {
|
|
4158
5067
|
const hooks = await (m.Tezgah || m.default)({ directory: root, worktree: root,
|
|
4159
5068
|
client: noop, $: noop });
|
|
@@ -4316,6 +5225,123 @@ def live_plan(host, deny=False):
|
|
|
4316
5225
|
if registered is None:
|
|
4317
5226
|
return plan + ("registration unread: the omp CLI did not answer",)
|
|
4318
5227
|
return plan
|
|
5228
|
+
if host == "qwen":
|
|
5229
|
+
# the hook command Qwen runs for its shell tool, fed Qwen's own payload;
|
|
5230
|
+
# no qwen binary and no model call (tests/e2e_qwen.py drives the binary)
|
|
5231
|
+
path = os.path.join(QWEN, "settings.json")
|
|
5232
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event),
|
|
5233
|
+
"run_shell_command")
|
|
5234
|
+
if not cmd:
|
|
5235
|
+
return ("miss", "no tezgah %s command for run_shell_command in %s"
|
|
5236
|
+
% (event, path))
|
|
5237
|
+
payload = {"hook_event_name": event, "tool_name": "run_shell_command",
|
|
5238
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5239
|
+
if not deny:
|
|
5240
|
+
payload["tool_response"] = {"llmContent": "", "returnDisplay": ""}
|
|
5241
|
+
return ("shell", cmd, payload, {})
|
|
5242
|
+
if host == "droid":
|
|
5243
|
+
# the hook command Droid runs for Execute, fed Droid's own payload; no
|
|
5244
|
+
# droid binary and no model call (tests/e2e_droid.py drives the binary)
|
|
5245
|
+
path = os.path.join(DROID, "hooks.json")
|
|
5246
|
+
cmd = _hook_command(read_json(path, {}).get(event), "Execute")
|
|
5247
|
+
if not cmd:
|
|
5248
|
+
return ("miss", "no tezgah %s command for Execute in %s" % (event, path))
|
|
5249
|
+
payload = {"hook_event_name": event, "tool_name": "Execute",
|
|
5250
|
+
"permission_mode": "auto-low",
|
|
5251
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5252
|
+
if not deny:
|
|
5253
|
+
payload["tool_response"] = "\n\n[Process exited with code 0]"
|
|
5254
|
+
return ("shell", cmd, payload, {})
|
|
5255
|
+
if host == "commandcode":
|
|
5256
|
+
# the hook command Command Code runs for its shell tool (the matcher sees
|
|
5257
|
+
# the display name SHELL, case-insensitively), fed its own payload; no
|
|
5258
|
+
# binary and no model call
|
|
5259
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
5260
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell")
|
|
5261
|
+
if not cmd:
|
|
5262
|
+
return ("miss", "no tezgah %s command for shell in %s" % (event, path))
|
|
5263
|
+
payload = {"hook_event_name": event, "tool_name": "shell_command",
|
|
5264
|
+
"tool_display_name": "SHELL", "permission_mode": "default",
|
|
5265
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5266
|
+
if not deny:
|
|
5267
|
+
payload["tool_response"] = ""
|
|
5268
|
+
return ("shell", cmd, payload, {})
|
|
5269
|
+
if host == "copilot":
|
|
5270
|
+
# the hook command Copilot runs for its shell tool (a PascalCase
|
|
5271
|
+
# PreToolUse sees the Claude name Bash), fed its own payload; no binary
|
|
5272
|
+
# and no model call
|
|
5273
|
+
path = os.path.join(COPILOT, "hooks", "tezgah.json")
|
|
5274
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "Bash",
|
|
5275
|
+
own=True)
|
|
5276
|
+
if not cmd:
|
|
5277
|
+
return ("miss", "no tezgah %s command for Bash in %s" % (event, path))
|
|
5278
|
+
payload = {"hook_event_name": event, "tool_name": "Bash",
|
|
5279
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5280
|
+
if not deny:
|
|
5281
|
+
payload["tool_result"] = {"result_type": "success",
|
|
5282
|
+
"text_result_for_llm": "<exited with exit code 0>"}
|
|
5283
|
+
return ("shell", cmd, payload, {})
|
|
5284
|
+
if host == "gemini":
|
|
5285
|
+
# the hook command Gemini runs for its shell tool (BeforeTool/AfterTool),
|
|
5286
|
+
# fed Gemini's own payload; no binary and no model call
|
|
5287
|
+
path = os.path.join(GEMINI_EXT, "hooks", "hooks.json")
|
|
5288
|
+
name = "BeforeTool" if deny else "AfterTool"
|
|
5289
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(name),
|
|
5290
|
+
"run_shell_command", own=True)
|
|
5291
|
+
if not cmd:
|
|
5292
|
+
return ("miss", "no tezgah %s command for run_shell_command in %s"
|
|
5293
|
+
% (name, path))
|
|
5294
|
+
payload = {"hook_event_name": name, "tool_name": "run_shell_command",
|
|
5295
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny else "true"}}
|
|
5296
|
+
if not deny:
|
|
5297
|
+
payload["tool_response"] = {"llmContent": "Output: (empty)",
|
|
5298
|
+
"returnDisplay": ""}
|
|
5299
|
+
return ("shell", cmd, payload, {})
|
|
5300
|
+
if host == "antigravity":
|
|
5301
|
+
# the hook command Antigravity runs for run_command, under the `tezgah`
|
|
5302
|
+
# name of ~/.gemini/config/hooks.json; it carries the event as argv[1],
|
|
5303
|
+
# since the camelCase payload names none. No binary and no model call
|
|
5304
|
+
cmd = _hook_command((read_json(AGY_HOOKS, {}).get("tezgah") or {}).get(event),
|
|
5305
|
+
"run_command", own=True)
|
|
5306
|
+
if not cmd:
|
|
5307
|
+
return ("miss", "no tezgah %s command for run_command in %s" % (event, AGY_HOOKS))
|
|
5308
|
+
payload = {"toolCall": {"name": "run_command", "args": {
|
|
5309
|
+
"CommandLine": LIVE_DENY_COMMAND if deny else "true", "WaitMsBeforeAsync": 5000}},
|
|
5310
|
+
"stepIdx": 0}
|
|
5311
|
+
if not deny:
|
|
5312
|
+
payload["error"] = ""
|
|
5313
|
+
return ("shell", cmd, payload, {})
|
|
5314
|
+
if host == "goose":
|
|
5315
|
+
# the hook command Goose runs for its `shell` tool, from tezgah's plugin
|
|
5316
|
+
# hooks.json, fed Goose's own payload (the event in `event`, the
|
|
5317
|
+
# directory as `working_dir`); no binary and no model call
|
|
5318
|
+
path = os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json")
|
|
5319
|
+
cmd = _hook_command((read_json(path, {}).get("hooks") or {}).get(event), "shell",
|
|
5320
|
+
own=True)
|
|
5321
|
+
if not cmd:
|
|
5322
|
+
return ("miss", "no tezgah %s command for shell in %s" % (event, path))
|
|
5323
|
+
return ("shell", cmd, {"event": event, "tool_name": "shell",
|
|
5324
|
+
"matcher_context": "shell", "tool_call_id": "tezgah-live",
|
|
5325
|
+
"tool_input": {"command": LIVE_DENY_COMMAND if deny
|
|
5326
|
+
else "true"}}, {})
|
|
5327
|
+
if host == "amp":
|
|
5328
|
+
# the installed plugin itself, loaded by node through LIVE_DRIVER and fed
|
|
5329
|
+
# Amp's own event shape (`tool`, `input.cmd`, `thread.id`); the hook it
|
|
5330
|
+
# spawns is the one Amp would spawn
|
|
5331
|
+
try:
|
|
5332
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
5333
|
+
text = fh.read()
|
|
5334
|
+
except OSError:
|
|
5335
|
+
return ("miss", "no plugin at %s" % AMP_PLUGIN)
|
|
5336
|
+
if text != ta.amp_plugin(HERE):
|
|
5337
|
+
return ("miss", "%s differs from a fresh render of this tree's plugin "
|
|
5338
|
+
"- re-run `tezgah-setup --install --hosts amp`" % AMP_PLUGIN)
|
|
5339
|
+
node = _node_for_ts()
|
|
5340
|
+
if not node:
|
|
5341
|
+
return ("unverified", "no node 22.6+ to load %s" % AMP_PLUGIN)
|
|
5342
|
+
# always unverified: Amp's own Bun loader is not what ran it
|
|
5343
|
+
return ("node", [node, "--experimental-strip-types", "--no-warnings"], AMP_PLUGIN,
|
|
5344
|
+
"Amp's own plugin loader is not driven here")
|
|
4319
5345
|
return ("unverified", "no live probe for this host")
|
|
4320
5346
|
|
|
4321
5347
|
|
|
@@ -4333,6 +5359,21 @@ def _denied(host, stdout):
|
|
|
4333
5359
|
return got.get("block") is True
|
|
4334
5360
|
if host == "opencode":
|
|
4335
5361
|
return bool(got.get("threw"))
|
|
5362
|
+
if host == "copilot":
|
|
5363
|
+
# top level, not wrapped (hooks-reference "preToolUse decision control")
|
|
5364
|
+
return got.get("permissionDecision") == "deny"
|
|
5365
|
+
if host == "gemini":
|
|
5366
|
+
# top level (hooks reference "BeforeTool"; `block` is its alias)
|
|
5367
|
+
return got.get("decision") in ("deny", "block")
|
|
5368
|
+
if host == "antigravity":
|
|
5369
|
+
# top level; `decision` is required, so an allow answers too (docs/hooks)
|
|
5370
|
+
return got.get("decision") == "deny"
|
|
5371
|
+
if host == "goose":
|
|
5372
|
+
# top level, stdout starting with `{` (hooks "Blocking a Tool Call")
|
|
5373
|
+
return got.get("decision") == "block"
|
|
5374
|
+
if host == "amp":
|
|
5375
|
+
# ToolCallResult (plugin-api): the call is refused, the turn goes on
|
|
5376
|
+
return got.get("action") == "reject-and-continue"
|
|
4336
5377
|
return (got.get("hookSpecificOutput") or {}).get("permissionDecision") == "deny"
|
|
4337
5378
|
|
|
4338
5379
|
|
|
@@ -4365,7 +5406,13 @@ def live_probe(host, deny=False):
|
|
|
4365
5406
|
if plan[0] == "shell":
|
|
4366
5407
|
_kind, cmd, payload, extra = plan
|
|
4367
5408
|
env.update(extra)
|
|
4368
|
-
|
|
5409
|
+
# Antigravity's envelope is camelCase, with no session_id or cwd;
|
|
5410
|
+
# Goose names the directory `working_dir`
|
|
5411
|
+
payload = (dict(payload, conversationId=sid, workspacePaths=[root])
|
|
5412
|
+
if host == "antigravity" else
|
|
5413
|
+
dict(payload, session_id=sid, working_dir=root)
|
|
5414
|
+
if host == "goose" else
|
|
5415
|
+
dict(payload, session_id=sid, conversation_id=sid, cwd=root))
|
|
4369
5416
|
proc = subprocess.run(cmd, shell=True, input=json.dumps(payload),
|
|
4370
5417
|
capture_output=True, text=True, env=env,
|
|
4371
5418
|
cwd=root, timeout=LIVE_TIMEOUT)
|
|
@@ -4438,7 +5485,9 @@ ARMING_ROWS = ("hooks.json wired", "plugin wired (link or copy)",
|
|
|
4438
5485
|
"carries the contract", "contract in instructions",
|
|
4439
5486
|
"claude-code hooks bridge in cordis.patch.yml",
|
|
4440
5487
|
"session, prompt, tool and stop hooks wired",
|
|
4441
|
-
"hook registered in omp extensions", "plugin copy current"
|
|
5488
|
+
"hook registered in omp extensions", "plugin copy current",
|
|
5489
|
+
"settings.json hooks wired", "hooks/tezgah.json wired",
|
|
5490
|
+
"plugin tezgah.ts current")
|
|
4442
5491
|
|
|
4443
5492
|
|
|
4444
5493
|
def arming_miss(label, good):
|
|
@@ -4704,14 +5753,17 @@ def claude_mcp_rows(copies):
|
|
|
4704
5753
|
return rows
|
|
4705
5754
|
|
|
4706
5755
|
|
|
4707
|
-
def _hooks_has(path, marker, events=None):
|
|
5756
|
+
def _hooks_has(path, marker, events=None, bare=False, key="hooks"):
|
|
4708
5757
|
"""True when `marker` is wired under every event in `events`, or under any
|
|
4709
|
-
event when `events` is None.
|
|
5758
|
+
event when `events` is None. `bare`: the file is keyed by event at the top
|
|
5759
|
+
(Droid's hooks.json); `key`: the top-level name the events sit under
|
|
5760
|
+
(`hooks`, or Antigravity's `tezgah`).
|
|
4710
5761
|
|
|
4711
5762
|
A row that one event can satisfy answers the wrong question: a manifest
|
|
4712
5763
|
carrying tezgah's command under SessionStart and not under Stop reads as
|
|
4713
5764
|
wired, and Stop is the integrity refusal."""
|
|
4714
|
-
|
|
5765
|
+
data = read_json(path, {}) or {}
|
|
5766
|
+
hooks = data if bare else data.get(key) or {}
|
|
4715
5767
|
if events is None:
|
|
4716
5768
|
return any(marker in json.dumps(e) for arr in hooks.values() for e in arr)
|
|
4717
5769
|
return all(any(marker in json.dumps(e) for e in (hooks.get(ev) or []))
|
|
@@ -4850,6 +5902,157 @@ def host_checks_cursor():
|
|
|
4850
5902
|
]
|
|
4851
5903
|
|
|
4852
5904
|
|
|
5905
|
+
def host_checks_qwen():
|
|
5906
|
+
path = os.path.join(QWEN, "settings.json")
|
|
5907
|
+
# a relocated home (QWEN_HOME) is named, as codex's is
|
|
5908
|
+
where = " in %s" % QWEN if QWEN != os.path.join(tp.HOME, ".qwen") else ""
|
|
5909
|
+
ui = read_json(path, {}).get("ui")
|
|
5910
|
+
return [
|
|
5911
|
+
("settings.json hooks wired%s" % where,
|
|
5912
|
+
_hooks_has(path, "tezgah-qwen-hook", [ev for ev, _m, _t in QWEN_HOOK_EVENTS])),
|
|
5913
|
+
("skills linked%s" % where, skills_linked(os.path.join(QWEN, "skills"))),
|
|
5914
|
+
("QWEN.md carries the contract (matches a fresh render)", rules_current("qwen")),
|
|
5915
|
+
("statusLine in settings.json ui",
|
|
5916
|
+
"tezgah-statusline" in json.dumps((ui if isinstance(ui, dict) else {})
|
|
5917
|
+
.get("statusLine") or {})),
|
|
5918
|
+
("qwen CLI on PATH", bool(tp.which_user("qwen"))),
|
|
5919
|
+
]
|
|
5920
|
+
|
|
5921
|
+
|
|
5922
|
+
def host_checks_droid():
|
|
5923
|
+
path = os.path.join(DROID, "hooks.json")
|
|
5924
|
+
why = droid_hooks_blocked()
|
|
5925
|
+
settings = read_jsonc(os.path.join(DROID, "settings.json")) or {}
|
|
5926
|
+
status = settings.get("statusLine")
|
|
5927
|
+
return [
|
|
5928
|
+
("hooks.json wired" + (" (not written: %s)" % why if why else ""),
|
|
5929
|
+
_hooks_has(path, "tezgah-droid-hook", [ev for ev, _m, _t in DROID_HOOK_EVENTS],
|
|
5930
|
+
bare=True)),
|
|
5931
|
+
("skills linked", skills_linked(os.path.join(DROID, "skills"))),
|
|
5932
|
+
("AGENTS.md carries the contract (matches a fresh render)", rules_current("droid")),
|
|
5933
|
+
("statusLine in settings.json", "tezgah-statusline" in json.dumps(status or {})),
|
|
5934
|
+
("includeCoAuthoredByDroid off in settings.json",
|
|
5935
|
+
settings.get("includeCoAuthoredByDroid") is False),
|
|
5936
|
+
("droid CLI on PATH", bool(tp.which_user("droid"))),
|
|
5937
|
+
]
|
|
5938
|
+
|
|
5939
|
+
|
|
5940
|
+
def host_checks_commandcode():
|
|
5941
|
+
path = os.path.join(COMMANDCODE, "settings.json")
|
|
5942
|
+
attr = read_json(path, {}).get("attribution")
|
|
5943
|
+
return [
|
|
5944
|
+
("settings.json hooks wired",
|
|
5945
|
+
_hooks_has(path, "tezgah-commandcode-hook",
|
|
5946
|
+
[ev for ev, _m, _t in COMMANDCODE_HOOK_EVENTS])),
|
|
5947
|
+
("skills linked", skills_linked(os.path.join(COMMANDCODE, "skills"))),
|
|
5948
|
+
("AGENTS.md carries the contract (matches a fresh render)",
|
|
5949
|
+
rules_current("commandcode")),
|
|
5950
|
+
("attribution.commit off in settings.json",
|
|
5951
|
+
isinstance(attr, dict) and attr.get("commit") == ""),
|
|
5952
|
+
("command-code CLI on PATH", bool(tp.which_user("command-code"))),
|
|
5953
|
+
]
|
|
5954
|
+
|
|
5955
|
+
|
|
5956
|
+
def host_checks_copilot():
|
|
5957
|
+
# a relocated home (COPILOT_HOME) is named, as codex's is
|
|
5958
|
+
where = " in %s" % COPILOT if COPILOT != os.path.join(tp.HOME, ".copilot") else ""
|
|
5959
|
+
settings = read_jsonc(os.path.join(COPILOT, "settings.json")) or {}
|
|
5960
|
+
return [
|
|
5961
|
+
("hooks/tezgah.json wired%s" % where,
|
|
5962
|
+
_hooks_has(os.path.join(COPILOT, "hooks", "tezgah.json"), "tezgah-copilot-hook",
|
|
5963
|
+
[ev for ev, _m, _t in COPILOT_HOOK_EVENTS])),
|
|
5964
|
+
("skills linked%s" % where, skills_linked(os.path.join(COPILOT, "skills"))),
|
|
5965
|
+
("copilot-instructions.md carries the contract (matches a fresh render)",
|
|
5966
|
+
rules_current("copilot")),
|
|
5967
|
+
("statusLine in settings.json",
|
|
5968
|
+
"tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
|
|
5969
|
+
("includeCoAuthoredBy off in settings.json",
|
|
5970
|
+
settings.get("includeCoAuthoredBy") is False),
|
|
5971
|
+
("copilot CLI on PATH", bool(tp.which_user("copilot"))),
|
|
5972
|
+
]
|
|
5973
|
+
|
|
5974
|
+
|
|
5975
|
+
def host_checks_gemini():
|
|
5976
|
+
# a relocated home (GEMINI_CLI_HOME) is named, as codex's is
|
|
5977
|
+
where = " in %s" % GEMINI if GEMINI != os.path.join(tp.HOME, ".gemini") else ""
|
|
5978
|
+
hooks_cfg = (read_jsonc(os.path.join(GEMINI, "settings.json")) or {}).get("hooksConfig")
|
|
5979
|
+
return [
|
|
5980
|
+
# the hooks load only beside a manifest naming the extension
|
|
5981
|
+
("extension hooks/hooks.json wired%s" % where,
|
|
5982
|
+
_hooks_has(os.path.join(GEMINI_EXT, "hooks", "hooks.json"), "tezgah-gemini-hook",
|
|
5983
|
+
[ev for ev, _m, _t in GEMINI_HOOK_EVENTS])
|
|
5984
|
+
and read_json(os.path.join(GEMINI_EXT, "gemini-extension.json"), {})
|
|
5985
|
+
.get("name") == "tezgah"),
|
|
5986
|
+
("skills linked%s" % where, skills_linked(os.path.join(GEMINI_EXT, "skills"))),
|
|
5987
|
+
("GEMINI.md carries the contract (matches a fresh render)", rules_current("gemini")),
|
|
5988
|
+
# `hooksConfig.enabled: false` runs no hook at all (configuration)
|
|
5989
|
+
("hooks left on in settings.json (hooksConfig.enabled)",
|
|
5990
|
+
not (isinstance(hooks_cfg, dict) and hooks_cfg.get("enabled") is False)),
|
|
5991
|
+
("gemini CLI on PATH", bool(tp.which_user("gemini"))),
|
|
5992
|
+
]
|
|
5993
|
+
|
|
5994
|
+
|
|
5995
|
+
def host_checks_antigravity():
|
|
5996
|
+
settings = read_json(os.path.join(ANTIGRAVITY, "settings.json"), {})
|
|
5997
|
+
mine = read_json(AGY_HOOKS, {}).get("tezgah")
|
|
5998
|
+
return [
|
|
5999
|
+
("config/hooks.json wired (the `tezgah` name)",
|
|
6000
|
+
_hooks_has(AGY_HOOKS, "tezgah-antigravity-hook",
|
|
6001
|
+
[ev for ev, _m, _t in ANTIGRAVITY_HOOK_EVENTS], key="tezgah")),
|
|
6002
|
+
# `enabled: false` on a hook name loads none of its events (docs/hooks)
|
|
6003
|
+
("tezgah hook set left enabled in config/hooks.json",
|
|
6004
|
+
not (isinstance(mine, dict) and mine.get("enabled") is False)),
|
|
6005
|
+
("skills linked", skills_linked(os.path.join(ANTIGRAVITY, "skills"))),
|
|
6006
|
+
("GEMINI.md carries the contract (matches a fresh render)",
|
|
6007
|
+
rules_current("antigravity")),
|
|
6008
|
+
("statusLine in antigravity-cli/settings.json",
|
|
6009
|
+
"tezgah-statusline" in json.dumps(settings.get("statusLine") or {})),
|
|
6010
|
+
("agy CLI on PATH", bool(tp.which_user("agy"))),
|
|
6011
|
+
]
|
|
6012
|
+
|
|
6013
|
+
|
|
6014
|
+
def host_checks_goose():
|
|
6015
|
+
disabled = read_json(os.path.join(GOOSE, "settings.json"), {}).get("disabledPlugins")
|
|
6016
|
+
return [
|
|
6017
|
+
# the hooks load only from a plugin dir under ~/.agents/plugins
|
|
6018
|
+
("plugin hooks/hooks.json wired (~/.agents/plugins/tezgah)",
|
|
6019
|
+
_hooks_has(os.path.join(GOOSE_PLUGIN, "hooks", "hooks.json"), "tezgah-goose-hook",
|
|
6020
|
+
[ev for ev, _m, _t in GOOSE_HOOK_EVENTS])
|
|
6021
|
+
and read_json(os.path.join(GOOSE_PLUGIN, "plugin.json"), {}).get("name") == "tezgah"),
|
|
6022
|
+
# a plugin named in `disabledPlugins` is skipped at discovery (plugins guide)
|
|
6023
|
+
("tezgah plugin not in disabledPlugins (settings.json)",
|
|
6024
|
+
not (isinstance(disabled, list) and "tezgah" in disabled)),
|
|
6025
|
+
("skills linked", skills_linked(os.path.join(GOOSE_PLUGIN, "skills"))),
|
|
6026
|
+
(".goosehints carries the contract (matches a fresh render)", rules_current("goose")),
|
|
6027
|
+
("goose CLI on PATH", bool(tp.which_user("goose"))),
|
|
6028
|
+
]
|
|
6029
|
+
|
|
6030
|
+
|
|
6031
|
+
def host_checks_amp():
|
|
6032
|
+
try:
|
|
6033
|
+
with open(AMP_PLUGIN, encoding="utf-8") as fh:
|
|
6034
|
+
plugin = fh.read()
|
|
6035
|
+
except OSError:
|
|
6036
|
+
plugin = ""
|
|
6037
|
+
# either user settings file; the comment-tolerant reader for both
|
|
6038
|
+
settings = (read_jsonc(os.path.join(AMP, "settings.json")) or {}
|
|
6039
|
+
if not os.path.exists(os.path.join(AMP, "settings.jsonc"))
|
|
6040
|
+
else read_jsonc(os.path.join(AMP, "settings.jsonc")) or {})
|
|
6041
|
+
return [
|
|
6042
|
+
# byte for byte, as omp's bridge row: a hand edit or an older tree's
|
|
6043
|
+
# render is not the wiring this tree arms
|
|
6044
|
+
("plugin tezgah.ts current (matches a fresh render): %s" % AMP_PLUGIN,
|
|
6045
|
+
bool(plugin) and plugin == ta.amp_plugin(HERE)),
|
|
6046
|
+
("skills linked", skills_linked(os.path.join(AMP, "skills"))),
|
|
6047
|
+
("AGENTS.md carries the contract (matches a fresh render)", rules_current("amp")),
|
|
6048
|
+
("%s off in settings.json" % AMP_ATTRIBUTION[0],
|
|
6049
|
+
settings.get(AMP_ATTRIBUTION[0]) is False),
|
|
6050
|
+
("%s off in settings.json" % AMP_ATTRIBUTION[1],
|
|
6051
|
+
settings.get(AMP_ATTRIBUTION[1]) is False),
|
|
6052
|
+
("amp CLI on PATH", bool(tp.which_user("amp"))),
|
|
6053
|
+
]
|
|
6054
|
+
|
|
6055
|
+
|
|
4853
6056
|
def host_checks_dsh():
|
|
4854
6057
|
try:
|
|
4855
6058
|
with open(DSH_PATCH, encoding="utf-8") as fh:
|
|
@@ -4968,7 +6171,11 @@ def host_checks_omp():
|
|
|
4968
6171
|
|
|
4969
6172
|
HOST_CHECKS = {"claude": host_checks_claude, "codex": host_checks_codex,
|
|
4970
6173
|
"opencode": host_checks_opencode, "cursor": host_checks_cursor,
|
|
4971
|
-
"dsh": host_checks_dsh, "omp": host_checks_omp
|
|
6174
|
+
"dsh": host_checks_dsh, "omp": host_checks_omp, "qwen": host_checks_qwen,
|
|
6175
|
+
"droid": host_checks_droid, "commandcode": host_checks_commandcode,
|
|
6176
|
+
"copilot": host_checks_copilot, "gemini": host_checks_gemini,
|
|
6177
|
+
"antigravity": host_checks_antigravity, "goose": host_checks_goose,
|
|
6178
|
+
"amp": host_checks_amp}
|
|
4972
6179
|
|
|
4973
6180
|
|
|
4974
6181
|
def versions_agree():
|