@ictechgy/context-guard 0.4.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.
Files changed (71) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +4 -0
  4. package/README.ko.md +353 -0
  5. package/README.md +353 -0
  6. package/context-guard-kit/README.md +76 -0
  7. package/context-guard-kit/benchmark_runner.py +1898 -0
  8. package/context-guard-kit/claude_transcript_cost_audit.py +1591 -0
  9. package/context-guard-kit/context_compress.py +543 -0
  10. package/context-guard-kit/context_escrow.py +919 -0
  11. package/context-guard-kit/context_guard_cli.py +149 -0
  12. package/context-guard-kit/context_guard_diet.py +1036 -0
  13. package/context-guard-kit/context_pack.py +929 -0
  14. package/context-guard-kit/failed_attempt_nudge.py +567 -0
  15. package/context-guard-kit/guard_large_read.py +690 -0
  16. package/context-guard-kit/hook_secret_patterns.py +43 -0
  17. package/context-guard-kit/read_symbol.py +483 -0
  18. package/context-guard-kit/rewrite_bash_for_token_budget.py +501 -0
  19. package/context-guard-kit/sanitize_output.py +725 -0
  20. package/context-guard-kit/settings.example.json +67 -0
  21. package/context-guard-kit/setup_wizard.py +1724 -0
  22. package/context-guard-kit/statusline.sh +362 -0
  23. package/context-guard-kit/statusline_merged.sh +157 -0
  24. package/context-guard-kit/tool_schema_pruner.py +837 -0
  25. package/context-guard-kit/trim_command_output.py +1098 -0
  26. package/docs/distribution.md +55 -0
  27. package/package.json +70 -0
  28. package/packaging/homebrew/context-guard.rb.template +34 -0
  29. package/plugins/context-guard/.claude-plugin/plugin.json +41 -0
  30. package/plugins/context-guard/LICENSE +201 -0
  31. package/plugins/context-guard/NOTICE +4 -0
  32. package/plugins/context-guard/README.ko.md +135 -0
  33. package/plugins/context-guard/README.md +135 -0
  34. package/plugins/context-guard/bin/claude-read-symbol +6 -0
  35. package/plugins/context-guard/bin/claude-sanitize-output +6 -0
  36. package/plugins/context-guard/bin/claude-token-artifact +6 -0
  37. package/plugins/context-guard/bin/claude-token-audit +6 -0
  38. package/plugins/context-guard/bin/claude-token-bench +6 -0
  39. package/plugins/context-guard/bin/claude-token-diet +6 -0
  40. package/plugins/context-guard/bin/claude-token-failed-nudge +6 -0
  41. package/plugins/context-guard/bin/claude-token-guard-read +6 -0
  42. package/plugins/context-guard/bin/claude-token-rewrite-bash +6 -0
  43. package/plugins/context-guard/bin/claude-token-setup +6 -0
  44. package/plugins/context-guard/bin/claude-token-statusline +6 -0
  45. package/plugins/context-guard/bin/claude-token-statusline-merged +6 -0
  46. package/plugins/context-guard/bin/claude-trim-output +6 -0
  47. package/plugins/context-guard/bin/context-guard +149 -0
  48. package/plugins/context-guard/bin/context-guard-artifact +919 -0
  49. package/plugins/context-guard/bin/context-guard-audit +1591 -0
  50. package/plugins/context-guard/bin/context-guard-bench +1898 -0
  51. package/plugins/context-guard/bin/context-guard-compress +543 -0
  52. package/plugins/context-guard/bin/context-guard-diet +1036 -0
  53. package/plugins/context-guard/bin/context-guard-failed-nudge +567 -0
  54. package/plugins/context-guard/bin/context-guard-guard-read +690 -0
  55. package/plugins/context-guard/bin/context-guard-pack +929 -0
  56. package/plugins/context-guard/bin/context-guard-read-symbol +483 -0
  57. package/plugins/context-guard/bin/context-guard-rewrite-bash +501 -0
  58. package/plugins/context-guard/bin/context-guard-sanitize-output +725 -0
  59. package/plugins/context-guard/bin/context-guard-setup +1724 -0
  60. package/plugins/context-guard/bin/context-guard-statusline +362 -0
  61. package/plugins/context-guard/bin/context-guard-statusline-merged +157 -0
  62. package/plugins/context-guard/bin/context-guard-tool-prune +837 -0
  63. package/plugins/context-guard/bin/context-guard-trim-output +1098 -0
  64. package/plugins/context-guard/brief/README.md +65 -0
  65. package/plugins/context-guard/brief/brief-mode.lite.md +29 -0
  66. package/plugins/context-guard/brief/brief-mode.standard.md +31 -0
  67. package/plugins/context-guard/brief/brief-mode.ultra.md +32 -0
  68. package/plugins/context-guard/lib/hook_secret_patterns.py +43 -0
  69. package/plugins/context-guard/skills/audit/SKILL.md +39 -0
  70. package/plugins/context-guard/skills/optimize/SKILL.md +48 -0
  71. package/plugins/context-guard/skills/setup/SKILL.md +40 -0
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ if [[ -t 0 ]]; then
5
+ echo "usage: pass Claude Code statusline JSON on stdin"
6
+ exit 0
7
+ fi
8
+
9
+ statusline_input_tmp=''
10
+
11
+ statusline_tmp_base() {
12
+ local candidate="${TMPDIR:-/tmp}" resolved
13
+ if [[ "$candidate" != "/" ]]; then
14
+ candidate="${candidate%/}"
15
+ fi
16
+ if [[ -z "$candidate" || "$candidate" != /* || ! -d "$candidate" || ! -w "$candidate" ]]; then
17
+ candidate="/tmp"
18
+ fi
19
+ if resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
20
+ if [[ "$resolved" != "/" ]]; then
21
+ resolved="${resolved%/}"
22
+ fi
23
+ printf '%s\n' "${resolved:-/}"
24
+ else
25
+ printf '/tmp\n'
26
+ fi
27
+ }
28
+
29
+ statusline_input_max_bytes() {
30
+ local raw="${CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES:-${CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES:-65536}}" max=65536
31
+ if [[ "$raw" =~ ^[0-9]+$ ]] && (( ${#raw} <= 7 )); then
32
+ max=$((10#$raw))
33
+ fi
34
+ if (( max < 1 || max > 1048576 )); then
35
+ max=65536
36
+ fi
37
+ printf '%s\n' "$max"
38
+ }
39
+
40
+ statusline_context_warn_threshold() {
41
+ local raw="${CONTEXT_GUARD_STATUSLINE_CTX_WARN:-${CLAUDE_TOKEN_STATUSLINE_CTX_WARN:-80}}" threshold=80
42
+ if [[ "$raw" =~ ^[0-9]{1,3}$ ]]; then
43
+ threshold=$((10#$raw))
44
+ if (( threshold < 1 )); then
45
+ threshold=1
46
+ elif (( threshold > 100 )); then
47
+ threshold=100
48
+ fi
49
+ fi
50
+ printf '%s\n' "$threshold"
51
+ }
52
+
53
+ read_bounded_statusline_input() {
54
+ local max input_len tmp_base
55
+ max=$(statusline_input_max_bytes)
56
+ tmp_base=$(statusline_tmp_base)
57
+ statusline_input_tmp=$(mktemp "$tmp_base/context-guard-statusline.XXXXXX") || {
58
+ printf '[input-error] could not create statusline input buffer\n'
59
+ exit 0
60
+ }
61
+ trap 'rm -f "${statusline_input_tmp:-}"' EXIT
62
+ LC_ALL=C head -c "$((max + 1))" >"$statusline_input_tmp" 2>/dev/null || true
63
+ input_len=$(LC_ALL=C wc -c <"$statusline_input_tmp" | tr -d '[:space:]')
64
+ if (( input_len > max )); then
65
+ printf '[input-too-large] Claude statusline JSON exceeds %s bytes\n' "$max"
66
+ exit 0
67
+ fi
68
+ input=$(cat "$statusline_input_tmp" 2>/dev/null || true)
69
+ rm -f "$statusline_input_tmp"
70
+ statusline_input_tmp=''
71
+ trap - EXIT
72
+ }
73
+
74
+ read_bounded_statusline_input
75
+
76
+ if ! command -v jq >/dev/null 2>&1; then
77
+ echo "[needs-jq] install jq for Claude token statusline"
78
+ exit 0
79
+ fi
80
+
81
+ jq_get() {
82
+ jq -r "$1 // empty" <<<"$input" 2>/dev/null || true
83
+ }
84
+
85
+ strip_terminal_sequences() {
86
+ if command -v perl >/dev/null 2>&1; then
87
+ perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e[@-_][0-?]*[ -\/]*[@-~]//g'
88
+ else
89
+ cat
90
+ fi
91
+ }
92
+
93
+ sanitize_status() {
94
+ # Statusline values may come from untrusted workspace metadata; keep one-line printable text.
95
+ local cleaned
96
+ cleaned=$(printf '%s' "$1" \
97
+ | strip_terminal_sequences \
98
+ | LC_ALL=C tr '\r\n' ' ' \
99
+ | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177-\237' \
100
+ | cut -c 1-160)
101
+ if printf '%s' "$cleaned" | LC_ALL=C grep -Eiq '(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer[[:space:]]|Basic[[:space:]])'; then
102
+ printf '[redacted]'
103
+ else
104
+ printf '%s' "$cleaned"
105
+ fi
106
+ }
107
+
108
+ git_head_branch() {
109
+ # Keep the statusline cheap and non-blocking: do not invoke `git` here. Some
110
+ # workspaces have slow network filesystems, hydrated-on-demand git objects, or
111
+ # broken config; reading .git/HEAD is enough for a best-effort branch label.
112
+ local current="$1"
113
+ local dotgit gitdir_line gitdir head_file head_line branch
114
+ [[ -n "$current" && -d "$current" ]] || return 1
115
+ current=$(cd "$current" 2>/dev/null && pwd -P) || return 1
116
+
117
+ while [[ -n "$current" ]]; do
118
+ head_file=''
119
+ dotgit="$current/.git"
120
+ if [[ -d "$dotgit" && ! -L "$dotgit" ]]; then
121
+ head_file="$dotgit/HEAD"
122
+ elif [[ -f "$dotgit" && ! -L "$dotgit" ]]; then
123
+ IFS= read -r gitdir_line <"$dotgit" 2>/dev/null || gitdir_line=''
124
+ if [[ "$gitdir_line" == gitdir:\ * ]]; then
125
+ gitdir="${gitdir_line#gitdir: }"
126
+ [[ "$gitdir" == /* ]] || gitdir="$current/$gitdir"
127
+ if gitdir=$(cd "$gitdir" 2>/dev/null && pwd -P) && [[ -f "$gitdir/HEAD" && ! -L "$gitdir/HEAD" ]]; then
128
+ head_file="$gitdir/HEAD"
129
+ fi
130
+ fi
131
+ fi
132
+
133
+ if [[ -n "$head_file" && -f "$head_file" && ! -L "$head_file" ]]; then
134
+ IFS= read -r head_line <"$head_file" 2>/dev/null || return 1
135
+ if [[ "$head_line" == ref:\ refs/heads/* ]]; then
136
+ branch="${head_line#ref: refs/heads/}"
137
+ [[ -n "$branch" ]] && printf '%s\n' "$branch"
138
+ return 0
139
+ fi
140
+ if [[ "$head_line" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
141
+ printf '%s\n' "${head_line:0:12}"
142
+ return 0
143
+ fi
144
+ return 1
145
+ fi
146
+
147
+ [[ "$current" == "/" ]] && break
148
+ current="${current%/*}"
149
+ [[ -n "$current" ]] || current="/"
150
+ done
151
+ return 1
152
+ }
153
+
154
+ model=$(jq_get '.model.display_name')
155
+ model=${model:-$(jq_get '.model.id')}
156
+ model=${model:-unknown}
157
+ model=$(sanitize_status "$model")
158
+
159
+ context_raw=$(jq_get '.context_window.used_percentage')
160
+ context_is_numeric=0
161
+ if [[ -n "$context_raw" ]]; then
162
+ if context_pct=$(LC_NUMERIC=C printf '%.0f' "$context_raw" 2>/dev/null); then
163
+ if [[ "$context_pct" =~ ^-?[0-9]+$ ]]; then
164
+ context_is_numeric=1
165
+ else
166
+ context_pct=$(sanitize_status "$context_raw")
167
+ fi
168
+ else
169
+ context_pct=$(sanitize_status "$context_raw")
170
+ fi
171
+ else
172
+ context_pct="?"
173
+ fi
174
+ context_label="${context_pct}%"
175
+ if (( context_is_numeric )); then
176
+ context_warn_threshold=$(statusline_context_warn_threshold)
177
+ if (( context_pct >= context_warn_threshold )); then
178
+ context_label="${context_label} ⚠"
179
+ fi
180
+ fi
181
+
182
+ cost=$(jq_get '.cost.total_cost_usd')
183
+ if [[ -n "$cost" ]]; then
184
+ cost=$(printf '$%.3f' "$cost" 2>/dev/null || sanitize_status "$cost")
185
+ else
186
+ cost='n/a'
187
+ fi
188
+
189
+ cwd=$(jq_get '.workspace.current_dir')
190
+ dir=${cwd##*/}
191
+ dir=${dir:-.}
192
+ dir=$(sanitize_status "$dir")
193
+
194
+ branch=''
195
+ branch_dir=${cwd:-$PWD}
196
+ b=$(git_head_branch "$branch_dir" 2>/dev/null || true)
197
+ if [[ -n "$b" ]]; then
198
+ b=$(sanitize_status "$b")
199
+ branch=" | ${b}"
200
+ fi
201
+
202
+ # Cache metrics from the transcript tail (best-effort, fast — reads only the last 1MB).
203
+ # Stays empty when transcript is unavailable or python3 fails so the status line never breaks.
204
+ # NOTE: keep the token-key list and usage-extraction shape in sync with claude_transcript_cost_audit.py
205
+ # so the statusline metric matches the audit metric for the same transcript.
206
+ metrics_label=''
207
+ transcript_path=$(jq_get '.transcript_path')
208
+ if [[ -n "$transcript_path" && -r "$transcript_path" ]] && command -v python3 >/dev/null 2>&1; then
209
+ transcript_metrics=$(python3 - "$transcript_path" 2>/dev/null <<'PYEOF' || true
210
+ import json
211
+ import os
212
+ import stat
213
+ import sys
214
+
215
+ path = sys.argv[1] if len(sys.argv) > 1 else ""
216
+ if not path:
217
+ sys.exit(0)
218
+
219
+ # Bounded tail read so the statusline never stalls on huge transcripts.
220
+ TAIL_BYTES = 1024 * 1024
221
+ MAX_RECORDS = 300
222
+
223
+
224
+ def _int_or_zero(value):
225
+ """transcript usage 토큰값을 정수로 강제. bool은 int 서브클래스이므로 별도 차단."""
226
+ if isinstance(value, bool):
227
+ return 0
228
+ if isinstance(value, int):
229
+ return max(0, value)
230
+ return 0
231
+
232
+
233
+ def _extract_usage(record):
234
+ """transcript record에서 알려진 usage 객체 1개만 꺼낸다.
235
+
236
+ Claude Code transcript JSONL은 record 당 한 번의 LLM 호출 usage를 다음 중 한 자리에
237
+ 넣는 것이 일반적이다 — top-level "usage", "message.usage", "response.usage".
238
+ 재귀 walk 대신 알려진 경로만 보아야 동일 값이 여러 nested 사본으로 들어왔을 때
239
+ 이중 합산되는 문제를 피할 수 있다.
240
+ """
241
+ if not isinstance(record, dict):
242
+ return None
243
+ for path_keys in (("usage",), ("message", "usage"), ("response", "usage")):
244
+ cur = record
245
+ for key in path_keys:
246
+ if not isinstance(cur, dict):
247
+ cur = None
248
+ break
249
+ cur = cur.get(key)
250
+ if isinstance(cur, dict):
251
+ return cur
252
+ return None
253
+
254
+
255
+ input_tokens = cache_read = cache_creation = 0
256
+
257
+ def _open_regular_transcript(path):
258
+ flags = os.O_RDONLY
259
+ if hasattr(os, "O_CLOEXEC"):
260
+ flags |= os.O_CLOEXEC
261
+ if hasattr(os, "O_NOFOLLOW"):
262
+ flags |= os.O_NOFOLLOW
263
+ if hasattr(os, "O_NONBLOCK"):
264
+ flags |= os.O_NONBLOCK
265
+ st = os.lstat(path)
266
+ if not stat.S_ISREG(st.st_mode):
267
+ return None
268
+ fd = os.open(path, flags)
269
+ try:
270
+ opened = os.fstat(fd)
271
+ if not stat.S_ISREG(opened.st_mode):
272
+ os.close(fd)
273
+ return None
274
+ return fd, opened.st_size
275
+ except Exception:
276
+ os.close(fd)
277
+ raise
278
+
279
+
280
+ def _read_tail(fd, size):
281
+ read_size = min(size, TAIL_BYTES)
282
+ if size > read_size:
283
+ os.lseek(fd, size - read_size, os.SEEK_SET)
284
+ chunks = []
285
+ remaining = read_size
286
+ while remaining > 0:
287
+ chunk = os.read(fd, remaining)
288
+ if not chunk:
289
+ break
290
+ chunks.append(chunk)
291
+ remaining -= len(chunk)
292
+ return b"".join(chunks), read_size
293
+
294
+
295
+ try:
296
+ opened = _open_regular_transcript(path)
297
+ if opened is None:
298
+ sys.exit(0)
299
+ fd, size = opened
300
+ try:
301
+ chunk, read_size = _read_tail(fd, size)
302
+ finally:
303
+ os.close(fd)
304
+ lines = chunk.splitlines()
305
+ if size > read_size and lines:
306
+ # First line in the tail window is likely partial; drop it.
307
+ lines = lines[1:]
308
+ for raw in lines[-MAX_RECORDS:]:
309
+ if not raw.strip():
310
+ continue
311
+ try:
312
+ obj = json.loads(raw)
313
+ except Exception:
314
+ continue
315
+ usage = _extract_usage(obj)
316
+ if not usage:
317
+ continue
318
+ input_tokens += _int_or_zero(usage.get("input_tokens"))
319
+ cr = usage.get("cache_read_input_tokens")
320
+ if cr is None:
321
+ cr = usage.get("cacheRead")
322
+ cache_read += _int_or_zero(cr)
323
+ cc = usage.get("cache_creation_input_tokens")
324
+ if cc is None:
325
+ cc = usage.get("cacheCreation")
326
+ cache_creation += _int_or_zero(cc)
327
+ denom = input_tokens + cache_read + cache_creation
328
+ # Skip the label entirely on empty / cache-cold sessions so the user does not see a
329
+ # confusing "cache 0%" before the cache has had a chance to warm up.
330
+ if denom <= 0 or cache_read <= 0:
331
+ sys.exit(0)
332
+ pct = max(0.0, min(100.0, cache_read / denom * 100))
333
+ parts = [f"cache_pct={pct:.0f}"]
334
+ if cache_creation > 0:
335
+ parts.append(f"reuse_x={cache_read / cache_creation:.1f}")
336
+ print(" ".join(parts))
337
+ except Exception:
338
+ sys.exit(0)
339
+ PYEOF
340
+ )
341
+ if [[ -n "$transcript_metrics" ]]; then
342
+ cache_pct=''
343
+ reuse_x=''
344
+ for metric in $transcript_metrics; do
345
+ case "$metric" in
346
+ cache_pct=*) cache_pct="${metric#cache_pct=}" ;;
347
+ reuse_x=*) reuse_x="${metric#reuse_x=}" ;;
348
+ esac
349
+ done
350
+ if [[ -n "$cache_pct" ]]; then
351
+ cache_pct=$(sanitize_status "$cache_pct")
352
+ metrics_label=" | cache ${cache_pct}%"
353
+ if [[ -n "$reuse_x" ]]; then
354
+ reuse_x=$(sanitize_status "$reuse_x")
355
+ metrics_label+=" | reuse ${reuse_x}x"
356
+ fi
357
+ fi
358
+ fi
359
+ fi
360
+
361
+ # Keep it one line and cheap: this script runs locally and should not do expensive git status.
362
+ echo "[$model] ${dir}${branch} | ctx ${context_label} | cost ${cost}${metrics_label}"
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env bash
2
+ # OMC HUD 와 context-guard-statusline 을 하나로 결합하는 statusline wrapper.
3
+ #
4
+ # 동작 매트릭스:
5
+ # ─────────────────────────────────────────────────────────────────────────
6
+ # OMC HUD 존재? │ token-statusline 존재? │ 출력
7
+ # ─────────────────────────────────────────────────────────────────────────
8
+ # yes │ yes │ OMC HUD + cost/cache/reuse 결합 (1줄)
9
+ # yes │ no │ OMC HUD 단독
10
+ # no │ yes │ token-statusline 단독
11
+ # no │ no │ "[hud unavailable]"
12
+ # ─────────────────────────────────────────────────────────────────────────
13
+ #
14
+ # 입력: stdin 으로 Claude Code 가 넘기는 statusline JSON 한 줄.
15
+ # 출력: stdout 한 줄.
16
+ #
17
+ # 환경변수(선택, 테스트/커스텀 설치용):
18
+ # OMC_HUD_SCRIPT OMC HUD 스크립트 경로 (기본 $HOME/.claude/hud/omc-hud.mjs)
19
+ # CONTEXT_GUARD_STATUSLINE_BIN context-guard-statusline 바이너리 경로
20
+ # (legacy: CLAUDE_TOKEN_STATUSLINE_BIN)
21
+ # (미지정 시 자기 옆 디렉토리만 사용; PATH 탐색 안 함)
22
+ set -u
23
+
24
+ statusline_input_tmp=''
25
+
26
+ statusline_tmp_base() {
27
+ local candidate="${TMPDIR:-/tmp}" resolved
28
+ if [[ "$candidate" != "/" ]]; then
29
+ candidate="${candidate%/}"
30
+ fi
31
+ if [[ -z "$candidate" || "$candidate" != /* || ! -d "$candidate" || ! -w "$candidate" ]]; then
32
+ candidate="/tmp"
33
+ fi
34
+ if resolved=$(cd "$candidate" 2>/dev/null && pwd -P); then
35
+ if [[ "$resolved" != "/" ]]; then
36
+ resolved="${resolved%/}"
37
+ fi
38
+ printf '%s\n' "${resolved:-/}"
39
+ else
40
+ printf '/tmp\n'
41
+ fi
42
+ }
43
+
44
+ statusline_input_max_bytes() {
45
+ local raw="${CONTEXT_GUARD_STATUSLINE_INPUT_MAX_BYTES:-${CLAUDE_TOKEN_STATUSLINE_INPUT_MAX_BYTES:-65536}}" max=65536
46
+ if [[ "$raw" =~ ^[0-9]+$ ]] && (( ${#raw} <= 7 )); then
47
+ max=$((10#$raw))
48
+ fi
49
+ if (( max < 1 || max > 1048576 )); then
50
+ max=65536
51
+ fi
52
+ printf '%s\n' "$max"
53
+ }
54
+
55
+ read_bounded_statusline_input() {
56
+ local max input_len tmp_base
57
+ max=$(statusline_input_max_bytes)
58
+ tmp_base=$(statusline_tmp_base)
59
+ statusline_input_tmp=$(mktemp "$tmp_base/context-guard-statusline.XXXXXX") || {
60
+ printf '[input-error] could not create statusline input buffer\n'
61
+ exit 0
62
+ }
63
+ trap 'rm -f "${statusline_input_tmp:-}"' EXIT
64
+ LC_ALL=C head -c "$((max + 1))" >"$statusline_input_tmp" 2>/dev/null || true
65
+ input_len=$(LC_ALL=C wc -c <"$statusline_input_tmp" | tr -d '[:space:]')
66
+ if (( input_len > max )); then
67
+ printf '[input-too-large] Claude statusline JSON exceeds %s bytes\n' "$max"
68
+ exit 0
69
+ fi
70
+ input=$(cat "$statusline_input_tmp" 2>/dev/null || true)
71
+ rm -f "$statusline_input_tmp"
72
+ statusline_input_tmp=''
73
+ trap - EXIT
74
+ }
75
+
76
+ read_bounded_statusline_input
77
+
78
+ strip_terminal_sequences() {
79
+ if command -v perl >/dev/null 2>&1; then
80
+ perl -pe 's/\e\][^\a\e]*(?:\a|\e\\)//g; s/\e[@-_][0-?]*[ -\/]*[@-~]//g'
81
+ else
82
+ cat
83
+ fi
84
+ }
85
+
86
+ sanitize_statusline() {
87
+ # Claude statusline output must stay a single bounded terminal line. Treat
88
+ # helper output as display data, not trusted terminal control text.
89
+ local cleaned
90
+ cleaned=$(printf '%s' "$1" \
91
+ | strip_terminal_sequences \
92
+ | LC_ALL=C tr '\r\n' ' ' \
93
+ | LC_ALL=C tr -d '\000-\010\013\014\016-\037\177-\237' \
94
+ | cut -c 1-1000)
95
+ if printf '%s' "$cleaned" | LC_ALL=C grep -Eiq '(gh[pousr]_|github_pat_|glpat-|xox[abprs]-|AKIA|ASIA|sk-|npm_|AIza|Bearer[[:space:]]|Basic[[:space:]])'; then
96
+ printf '[redacted]'
97
+ else
98
+ printf '%s' "$cleaned"
99
+ fi
100
+ }
101
+
102
+ # ── 1) OMC HUD 출력 ──────────────────────────────────────────────────────────
103
+ omc_out=''
104
+ omc_script="${OMC_HUD_SCRIPT:-$HOME/.claude/hud/omc-hud.mjs}"
105
+ if [[ -r "$omc_script" ]] && command -v node >/dev/null 2>&1; then
106
+ omc_out=$(printf '%s' "$input" | node "$omc_script" 2>/dev/null || true)
107
+ omc_out=$(sanitize_statusline "$omc_out")
108
+ fi
109
+
110
+ # ── 2) context-guard-statusline 바이너리 위치 결정 ────────────────────────────
111
+ # 우선순위: 환경변수 → 자기 옆 디렉토리
112
+ # PATH fallback 은 workspace/plugin 경로 shadowing 으로 untrusted binary 를
113
+ # 실행할 수 있어 사용하지 않는다. 외부 바이너리를 쓰려면 명시적으로
114
+ # CONTEXT_GUARD_STATUSLINE_BIN 을 지정해야 한다.
115
+ tok_bin="${CONTEXT_GUARD_STATUSLINE_BIN:-${CLAUDE_TOKEN_STATUSLINE_BIN:-}}"
116
+ if [[ -z "$tok_bin" ]]; then
117
+ self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)"
118
+ for cand in \
119
+ "$self_dir/context-guard-statusline" \
120
+ "$self_dir/statusline.sh"; do
121
+ if [[ -x "$cand" ]]; then
122
+ tok_bin="$cand"
123
+ break
124
+ fi
125
+ done
126
+ fi
127
+ tok_out=''
128
+ if [[ -n "$tok_bin" && -x "$tok_bin" ]]; then
129
+ tok_out=$(printf '%s' "$input" | "$tok_bin" 2>/dev/null || true)
130
+ tok_out=$(sanitize_statusline "$tok_out")
131
+ fi
132
+
133
+ # ── 3) 결합: OMC HUD 가 살아있을 때만 token 출력에서 compact extras 만 뽑아 붙임 ─
134
+ # token-statusline 형식:
135
+ # "[model] dir | branch | ctx N% | cost $N.NNN | cache N% | reuse N.Nx"
136
+ # OMC HUD 와 중복되는 model/dir/branch/ctx 는 버리고 cost/cache/reuse 만 채택한다.
137
+ extras=''
138
+ if [[ -n "$omc_out" && -n "$tok_out" ]]; then
139
+ if [[ "$tok_out" =~ \|[[:space:]]+cost[[:space:]]+(\$[0-9.]+|n/a) ]]; then
140
+ extras+=" | cost ${BASH_REMATCH[1]}"
141
+ fi
142
+ if [[ "$tok_out" =~ \|[[:space:]]+cache[[:space:]]+([0-9]+%) ]]; then
143
+ extras+=" | cache ${BASH_REMATCH[1]}"
144
+ fi
145
+ if [[ "$tok_out" =~ \|[[:space:]]+reuse[[:space:]]+([0-9]+(\.[0-9]+)?x|n/a) ]]; then
146
+ extras+=" | reuse ${BASH_REMATCH[1]}"
147
+ fi
148
+ fi
149
+
150
+ # ── 4) 출력 ──────────────────────────────────────────────────────────────────
151
+ if [[ -n "$omc_out" ]]; then
152
+ printf '%s%s\n' "$omc_out" "$extras"
153
+ elif [[ -n "$tok_out" ]]; then
154
+ printf '%s\n' "$tok_out"
155
+ else
156
+ printf '[hud unavailable]\n'
157
+ fi