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.
@@ -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 "$@"; }