trigger-tree 1.14.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trigger_tree/__init__.py +1 -0
- trigger_tree/_scripts/tt-codex-hook.py +120 -0
- trigger_tree/_scripts/tt-config.sh +32 -0
- trigger_tree/_scripts/tt-doctor.py +288 -0
- trigger_tree/_scripts/tt-log.py +656 -0
- trigger_tree/_scripts/tt-open.sh +164 -0
- trigger_tree/_scripts/tt-publish-badge.sh +26 -0
- trigger_tree/_scripts/tt-report.py +623 -0
- trigger_tree/_scripts/tt-setup.py +266 -0
- trigger_tree/_scripts/tt-shell-capture.sh +48 -0
- trigger_tree/_scripts/tt-stats.py +937 -0
- trigger_tree/_scripts/tt-statusline.py +154 -0
- trigger_tree/_scripts/tt-suggestions.py +128 -0
- trigger_tree/_scripts/tt-tips.py +107 -0
- trigger_tree/_scripts/tt-uninstall.py +60 -0
- trigger_tree/_scripts/tt-watch.py +1013 -0
- trigger_tree/_scripts/tt_runtime.py +27 -0
- trigger_tree/_scripts/tt_scope.py +76 -0
- trigger_tree/_scripts/validate_palette.js +28 -0
- trigger_tree/cli.py +52 -0
- trigger_tree/hooks/claude-hooks.json +11 -0
- trigger_tree/hooks/hooks.json +9 -0
- trigger_tree-1.14.0.dist-info/METADATA +106 -0
- trigger_tree-1.14.0.dist-info/RECORD +27 -0
- trigger_tree-1.14.0.dist-info/WHEEL +4 -0
- trigger_tree-1.14.0.dist-info/entry_points.txt +2 -0
- trigger_tree-1.14.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""trigger-tree setup — wires the plugin into the current project. Idempotent.
|
|
3
|
+
|
|
4
|
+
Steps (each reported as created/updated/skipped):
|
|
5
|
+
1. .gitignore: ensure the .trigger-tree entries (data ignored, config committed).
|
|
6
|
+
2. Copy tt-statusline.py to $PROJECT/.claude/tt-statusline.py (plugins cannot ship
|
|
7
|
+
a statusLine, so the script must live in the project).
|
|
8
|
+
3. Register the statusline in $PROJECT/.claude/settings.json (only if none is set).
|
|
9
|
+
4. Create .trigger-tree/config.sh with recognizable truncated prompt previews by
|
|
10
|
+
default. Use --prompt-mode hash|truncate|off to make privacy explicit.
|
|
11
|
+
|
|
12
|
+
Usage: python3 tt-setup.py [--prompt-mode hash|truncate|off]
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
|
|
23
|
+
from tt_scope import is_poor_coverage, scan_markdown, suggested_regex
|
|
24
|
+
|
|
25
|
+
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
26
|
+
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
|
|
28
|
+
GITIGNORE_LINES = [".trigger-tree/*", "!.trigger-tree/config.sh"]
|
|
29
|
+
# python3-with-fallback so the same registration works on macOS/Linux/Windows(Git Bash)
|
|
30
|
+
STATUSLINE_CMD = (
|
|
31
|
+
'python3 "$CLAUDE_PROJECT_DIR"/.claude/tt-statusline.py 2>/dev/null'
|
|
32
|
+
' || python "$CLAUDE_PROJECT_DIR"/.claude/tt-statusline.py'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def report(action, target):
|
|
37
|
+
print(f"{action:8s} {target}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def assert_safe_destination(path, allow_directory=False):
|
|
41
|
+
"""Refuse project-controlled symlinks and non-regular write destinations."""
|
|
42
|
+
root = os.path.abspath(ROOT)
|
|
43
|
+
target = os.path.abspath(path)
|
|
44
|
+
if os.path.commonpath((root, target)) != root:
|
|
45
|
+
raise RuntimeError(f"refusing destination outside project: {path}")
|
|
46
|
+
current = root
|
|
47
|
+
for part in os.path.relpath(target, root).split(os.sep):
|
|
48
|
+
current = os.path.join(current, part)
|
|
49
|
+
if not os.path.lexists(current):
|
|
50
|
+
continue
|
|
51
|
+
mode = os.lstat(current).st_mode
|
|
52
|
+
if stat.S_ISLNK(mode):
|
|
53
|
+
raise RuntimeError(f"refusing symlink destination: {current}")
|
|
54
|
+
if current != target and not stat.S_ISDIR(mode):
|
|
55
|
+
raise RuntimeError(f"refusing non-directory parent: {current}")
|
|
56
|
+
if (
|
|
57
|
+
current == target
|
|
58
|
+
and not stat.S_ISREG(mode)
|
|
59
|
+
and not (allow_directory and stat.S_ISDIR(mode))
|
|
60
|
+
):
|
|
61
|
+
raise RuntimeError(f"refusing non-regular destination: {current}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def atomic_write(path, content, mode=0o644):
|
|
65
|
+
"""Replace a checked project file without ever following the destination."""
|
|
66
|
+
assert_safe_destination(path)
|
|
67
|
+
parent = os.path.dirname(path)
|
|
68
|
+
assert_safe_destination(parent, allow_directory=True)
|
|
69
|
+
fd, temporary = tempfile.mkstemp(prefix=".trigger-tree-", dir=parent)
|
|
70
|
+
try:
|
|
71
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
72
|
+
fh.write(content)
|
|
73
|
+
os.chmod(temporary, mode)
|
|
74
|
+
os.replace(temporary, path)
|
|
75
|
+
finally:
|
|
76
|
+
try:
|
|
77
|
+
os.unlink(temporary)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ensure_gitignore():
|
|
83
|
+
path = os.path.join(ROOT, ".gitignore")
|
|
84
|
+
assert_safe_destination(path)
|
|
85
|
+
existing = ""
|
|
86
|
+
if os.path.isfile(path):
|
|
87
|
+
existing = open(path, encoding="utf-8").read()
|
|
88
|
+
missing = [ln for ln in GITIGNORE_LINES if ln not in existing.splitlines()]
|
|
89
|
+
if not missing:
|
|
90
|
+
report("skipped", ".gitignore (entries present)")
|
|
91
|
+
return
|
|
92
|
+
if existing and not existing.endswith("\n"):
|
|
93
|
+
existing += "\n"
|
|
94
|
+
atomic_write(path, existing + "\n".join(missing) + "\n")
|
|
95
|
+
report("updated", f".gitignore (+{len(missing)} entries)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def copy_statusline():
|
|
99
|
+
dst_dir = os.path.join(ROOT, ".claude")
|
|
100
|
+
assert_safe_destination(dst_dir, allow_directory=True)
|
|
101
|
+
os.makedirs(dst_dir, exist_ok=True)
|
|
102
|
+
dst = os.path.join(dst_dir, "tt-statusline.py")
|
|
103
|
+
source = open(os.path.join(SCRIPT_DIR, "tt-statusline.py"), encoding="utf-8").read()
|
|
104
|
+
atomic_write(dst, source, 0o755)
|
|
105
|
+
report("copied", ".claude/tt-statusline.py")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def register_statusline():
|
|
109
|
+
path = os.path.join(ROOT, ".claude", "settings.json")
|
|
110
|
+
assert_safe_destination(path)
|
|
111
|
+
settings = {}
|
|
112
|
+
if os.path.isfile(path):
|
|
113
|
+
try:
|
|
114
|
+
settings = json.load(open(path, encoding="utf-8"))
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
report("skipped", ".claude/settings.json (unparseable — register statusLine manually)")
|
|
117
|
+
return
|
|
118
|
+
if "statusLine" in settings:
|
|
119
|
+
if settings["statusLine"].get("command", "").endswith("tt-statusline.py"):
|
|
120
|
+
report("skipped", ".claude/settings.json (statusLine already registered)")
|
|
121
|
+
else:
|
|
122
|
+
report(
|
|
123
|
+
"skipped", ".claude/settings.json (different statusLine present — left untouched)"
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
settings["statusLine"] = {
|
|
127
|
+
"type": "command",
|
|
128
|
+
"command": STATUSLINE_CMD,
|
|
129
|
+
"refreshInterval": 5,
|
|
130
|
+
}
|
|
131
|
+
atomic_write(path, json.dumps(settings, indent=2) + "\n")
|
|
132
|
+
report("updated", ".claude/settings.json (statusLine registered)")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def prompt_mode_message(mode):
|
|
136
|
+
if mode == "truncate":
|
|
137
|
+
return "prompt previews: first 200 characters stored locally (gitignored)"
|
|
138
|
+
if mode == "hash":
|
|
139
|
+
return "prompt privacy: hashes only; historical previews unavailable"
|
|
140
|
+
return "prompt privacy: markers only; no text or hash stored"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def choose_prompt_mode(requested, config_exists, stream=None, input_fn=input):
|
|
144
|
+
"""Choose once for a new config; never block automation or rewrite an existing choice."""
|
|
145
|
+
if requested:
|
|
146
|
+
return requested, True
|
|
147
|
+
if config_exists:
|
|
148
|
+
return "truncate", False
|
|
149
|
+
stream = stream or sys.stdin
|
|
150
|
+
if not stream.isatty():
|
|
151
|
+
return "truncate", False
|
|
152
|
+
print("Prompt telemetry stays local and gitignored.")
|
|
153
|
+
print(" truncate: recognizable first 200 characters (default)")
|
|
154
|
+
print(" hash: stable fingerprint, no prompt text")
|
|
155
|
+
print(" off: event marker only")
|
|
156
|
+
while True:
|
|
157
|
+
answer = input_fn("Prompt mode [truncate/hash/off]: ").strip().lower()
|
|
158
|
+
aliases = {"": "truncate", "t": "truncate", "h": "hash", "o": "off"}
|
|
159
|
+
answer = aliases.get(answer, answer)
|
|
160
|
+
if answer in ("truncate", "hash", "off"):
|
|
161
|
+
return answer, True
|
|
162
|
+
print("Choose truncate, hash, or off.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def write_prompt_mode(path, mode):
|
|
166
|
+
assert_safe_destination(path)
|
|
167
|
+
with open(path, encoding="utf-8") as fh:
|
|
168
|
+
lines = fh.read().splitlines()
|
|
169
|
+
assignment = f"TT_LOG_PROMPTS='{mode}'"
|
|
170
|
+
changed = False
|
|
171
|
+
for index, line in enumerate(lines):
|
|
172
|
+
if line.strip().startswith("TT_LOG_PROMPTS="):
|
|
173
|
+
changed = line != assignment
|
|
174
|
+
lines[index] = assignment
|
|
175
|
+
break
|
|
176
|
+
else:
|
|
177
|
+
lines.append(assignment)
|
|
178
|
+
changed = True
|
|
179
|
+
if changed:
|
|
180
|
+
atomic_write(path, "\n".join(lines) + "\n")
|
|
181
|
+
return changed
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def write_assignment(path, name, value):
|
|
185
|
+
assert_safe_destination(path)
|
|
186
|
+
lines = open(path, encoding="utf-8").read().splitlines()
|
|
187
|
+
assignment = f"{name}='{value}'"
|
|
188
|
+
for index, line in enumerate(lines):
|
|
189
|
+
if line.strip().startswith(f"{name}="):
|
|
190
|
+
lines[index] = assignment
|
|
191
|
+
break
|
|
192
|
+
else:
|
|
193
|
+
lines.append(assignment)
|
|
194
|
+
atomic_write(path, "\n".join(lines) + "\n")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def effective_watch_regex():
|
|
198
|
+
for path in (
|
|
199
|
+
os.path.join(ROOT, ".trigger-tree", "config.sh"),
|
|
200
|
+
os.path.join(SCRIPT_DIR, "tt-config.sh"),
|
|
201
|
+
):
|
|
202
|
+
try:
|
|
203
|
+
text = open(path, encoding="utf-8").read()
|
|
204
|
+
except OSError:
|
|
205
|
+
continue
|
|
206
|
+
match = re.search(r"TT_WATCH_REGEX='([^']+)'", text)
|
|
207
|
+
if match:
|
|
208
|
+
return match.group(1)
|
|
209
|
+
return r"(?!)"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def audit_watch_scope(apply_suggestion=False):
|
|
213
|
+
result = scan_markdown(ROOT, effective_watch_regex())
|
|
214
|
+
suffix = f" (scan capped at {result['visited']} files)" if result["capped"] else ""
|
|
215
|
+
print(f"watched: {result['watched']} of {result['markdown']} markdown files{suffix}")
|
|
216
|
+
if not is_poor_coverage(result):
|
|
217
|
+
return
|
|
218
|
+
proposal = suggested_regex(result["paths"])
|
|
219
|
+
if not proposal:
|
|
220
|
+
return
|
|
221
|
+
print(f"suggested TT_WATCH_REGEX: {proposal}")
|
|
222
|
+
if apply_suggestion:
|
|
223
|
+
path = os.path.join(ROOT, ".trigger-tree", "config.sh")
|
|
224
|
+
write_assignment(path, "TT_WATCH_REGEX", proposal)
|
|
225
|
+
report("updated", ".trigger-tree/config.sh (applied suggested TT_WATCH_REGEX)")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def configure_prompts(mode, explicit=False):
|
|
229
|
+
dst_dir = os.path.join(ROOT, ".trigger-tree")
|
|
230
|
+
assert_safe_destination(dst_dir, allow_directory=True)
|
|
231
|
+
os.makedirs(dst_dir, mode=0o700, exist_ok=True)
|
|
232
|
+
os.chmod(dst_dir, 0o700)
|
|
233
|
+
dst = os.path.join(dst_dir, "config.sh")
|
|
234
|
+
if os.path.isfile(dst):
|
|
235
|
+
if explicit and write_prompt_mode(dst, mode):
|
|
236
|
+
report("updated", f".trigger-tree/config.sh ({prompt_mode_message(mode)})")
|
|
237
|
+
else:
|
|
238
|
+
report("skipped", ".trigger-tree/config.sh (existing prompt setting preserved)")
|
|
239
|
+
return
|
|
240
|
+
source = open(os.path.join(SCRIPT_DIR, "tt-config.sh"), encoding="utf-8").read()
|
|
241
|
+
atomic_write(dst, source)
|
|
242
|
+
write_prompt_mode(dst, mode)
|
|
243
|
+
report("created", f".trigger-tree/config.sh ({prompt_mode_message(mode)})")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def parse_args(argv=None):
|
|
247
|
+
parser = argparse.ArgumentParser()
|
|
248
|
+
parser.add_argument("--prompt-mode", choices=("truncate", "hash", "off"))
|
|
249
|
+
parser.add_argument("--apply-watch-suggestion", action="store_true")
|
|
250
|
+
return parser.parse_args(argv)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv=None):
|
|
254
|
+
args = parse_args(argv)
|
|
255
|
+
config_exists = os.path.isfile(os.path.join(ROOT, ".trigger-tree", "config.sh"))
|
|
256
|
+
prompt_mode, explicit = choose_prompt_mode(args.prompt_mode, config_exists)
|
|
257
|
+
ensure_gitignore()
|
|
258
|
+
copy_statusline()
|
|
259
|
+
register_statusline()
|
|
260
|
+
configure_prompts(prompt_mode, explicit=explicit)
|
|
261
|
+
audit_watch_scope(args.apply_watch_suggestion)
|
|
262
|
+
print("done — restart the session (or wait for settings reload) to activate the statusline")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
main()
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runtime Bash read capture. Sourced by Claude Code's CLAUDE_ENV_FILE preamble.
|
|
3
|
+
# Reader commands keep their normal stdout, stderr, arguments, and exit status.
|
|
4
|
+
|
|
5
|
+
# Claude may source its environment preamble from another POSIX shell. Runtime
|
|
6
|
+
# function capture is intentionally Bash/zsh-only; leave the marker unset so the
|
|
7
|
+
# PostToolUse logger keeps its conservative literal-path fallback elsewhere.
|
|
8
|
+
if [ -z "${BASH_VERSION:-}" ] && [ -z "${ZSH_VERSION:-}" ]; then
|
|
9
|
+
return 0
|
|
10
|
+
fi
|
|
11
|
+
export TT_RUNTIME_BASH_READS=1
|
|
12
|
+
|
|
13
|
+
_tt_capture_reader() {
|
|
14
|
+
local reader="$1"
|
|
15
|
+
shift
|
|
16
|
+
|
|
17
|
+
command "$reader" "$@"
|
|
18
|
+
local tt_exit_code=$?
|
|
19
|
+
local tt_candidate tt_should_log=0
|
|
20
|
+
if [ -z "${TT_SHELL_WATCH_SUFFIX:-}" ]; then
|
|
21
|
+
tt_should_log=1
|
|
22
|
+
else
|
|
23
|
+
for tt_candidate in "$@"; do
|
|
24
|
+
case "$tt_candidate" in
|
|
25
|
+
*"$TT_SHELL_WATCH_SUFFIX")
|
|
26
|
+
if [ -f "$tt_candidate" ]; then
|
|
27
|
+
tt_should_log=1
|
|
28
|
+
break
|
|
29
|
+
fi
|
|
30
|
+
;;
|
|
31
|
+
esac
|
|
32
|
+
done
|
|
33
|
+
fi
|
|
34
|
+
if [ "$tt_exit_code" -eq 0 ] && [ "$tt_should_log" -eq 1 ] && [ -n "${TT_SHELL_LOGGER:-}" ]; then
|
|
35
|
+
python3 "$TT_SHELL_LOGGER" shell-read "$reader" "$@" </dev/null >/dev/null 2>&1 ||
|
|
36
|
+
python "$TT_SHELL_LOGGER" shell-read "$reader" "$@" </dev/null >/dev/null 2>&1 || true
|
|
37
|
+
fi
|
|
38
|
+
return "$tt_exit_code"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# The `function name` form is deliberately alias-safe in both Bash and zsh.
|
|
42
|
+
# A user's existing alias may continue to take precedence; preserving their shell
|
|
43
|
+
# semantics is more important than forcing telemetry for that command.
|
|
44
|
+
function cat { _tt_capture_reader cat "$@"; }
|
|
45
|
+
function head { _tt_capture_reader head "$@"; }
|
|
46
|
+
function tail { _tt_capture_reader tail "$@"; }
|
|
47
|
+
function sed { _tt_capture_reader sed "$@"; }
|
|
48
|
+
function awk { _tt_capture_reader awk "$@"; }
|