zeno-cli 0.3.4__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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- zeno_session_intel/taxonomy.py +71 -0
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
"""`zeno hud install`: stack the zeno cognition bar UNDER the user's existing HUD.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes exactly one ``statusLine`` slot, so two HUDs cannot register
|
|
4
|
+
natively. The supported composition is to ride whatever popular HUD the user runs:
|
|
5
|
+
|
|
6
|
+
* **ccstatusline** (sirmalloc/ccstatusline): a native ``custom-command`` widget on
|
|
7
|
+
a dedicated extra line. Update-proof, no fork. Config lives at
|
|
8
|
+
``~/.config/ccstatusline/settings.json`` (read-merge-write, preserve the user's
|
|
9
|
+
widgets/lines).
|
|
10
|
+
* **claude-hud** (jarrodwatts/claude-hud): a thin ``zeno-hud-wrapper`` shell script
|
|
11
|
+
set as the ``statusLine`` that runs the real claude-hud then ``zeno-hud-bar`` and
|
|
12
|
+
concatenates their stdout, so Claude Code stacks the rows. (Built in Phase 3.)
|
|
13
|
+
|
|
14
|
+
Every edit is timestamped-backed-up and reversible (``zeno hud uninstall`` does a
|
|
15
|
+
surgical removal of only zeno-owned entries, or ``--restore`` copies the latest
|
|
16
|
+
backup back byte-for-byte). Nothing here ever runs the host HUD; it only wires the
|
|
17
|
+
plumbing, and the bar itself is crash-safe (an empty line on any error).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
from datetime import datetime
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
# A zeno-owned ccstatusline widget carries this metadata flag (metadata is a
|
|
29
|
+
# Record<string,string> in ccstatusline's schema), so re-install is idempotent and
|
|
30
|
+
# uninstall removes exactly our widget without touching the user's other widgets.
|
|
31
|
+
ZENO_META_KEY = "zeno-managed"
|
|
32
|
+
ZENO_META_VALUE = "hud-bar"
|
|
33
|
+
# ccstatusline's custom-command widget type id (its WidgetItem.type). The widget
|
|
34
|
+
# runs WidgetItem.commandPath via execSync, feeding it the session JSON on stdin.
|
|
35
|
+
CC_WIDGET_TYPE = "custom-command"
|
|
36
|
+
CC_WIDGET_ID = "zeno-hud-bar"
|
|
37
|
+
# ccstatusline SettingsSchema version at the time of writing (used only when we have
|
|
38
|
+
# to stamp a version onto an existing config that somehow lacks one; an existing
|
|
39
|
+
# config always already carries its own version, which we never rewrite).
|
|
40
|
+
CC_SETTINGS_VERSION = 2
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class HudInstallError(Exception):
|
|
44
|
+
"""Raised when a HUD config is unsafe to edit (malformed / wrong shape)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# shared helpers
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
def _timestamp() -> str:
|
|
51
|
+
# microseconds so a rapid re-install never collides on the backup stamp.
|
|
52
|
+
return datetime.now().strftime("%Y%m%dT%H%M%S%f")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _backup(path: Path, stamp: str) -> Path | None:
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return None
|
|
58
|
+
bak = path.with_name(f"{path.name}.zeno-bak.{stamp}")
|
|
59
|
+
shutil.copy2(path, bak)
|
|
60
|
+
return bak
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _latest_backup(path: Path) -> Path | None:
|
|
64
|
+
baks = sorted(path.parent.glob(f"{path.name}.zeno-bak.*"))
|
|
65
|
+
return baks[-1] if baks else None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
69
|
+
# Atomic: temp sibling then os.replace, so a crash mid-write can never truncate
|
|
70
|
+
# the user's live config (the exact failure this guards against).
|
|
71
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
tmp = path.with_name(f"{path.name}.zeno-tmp.{os.getpid()}")
|
|
73
|
+
tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
74
|
+
os.replace(tmp, path)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_bar_command() -> str:
|
|
78
|
+
"""The command a host HUD should run to render the zeno line.
|
|
79
|
+
|
|
80
|
+
Prefers an absolute path to the ``zeno-hud-bar`` console_script (most robust:
|
|
81
|
+
works regardless of the PATH Claude Code spawns the statusLine with), then the
|
|
82
|
+
``zeno`` console_script's ``hud bar`` subcommand, then the bare name as a last
|
|
83
|
+
resort. The bar reads the session JSON on stdin and prints exactly one line."""
|
|
84
|
+
direct = shutil.which("zeno-hud-bar")
|
|
85
|
+
if direct:
|
|
86
|
+
return direct
|
|
87
|
+
zeno = shutil.which("zeno")
|
|
88
|
+
if zeno:
|
|
89
|
+
return f"{zeno} hud bar"
|
|
90
|
+
return "zeno-hud-bar"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# ccstatusline adapter
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
def default_ccstatusline_path() -> Path:
|
|
97
|
+
"""``$XDG_CONFIG_HOME/ccstatusline/settings.json`` (default ``~/.config``)."""
|
|
98
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
99
|
+
return Path(base).expanduser() / "ccstatusline" / "settings.json"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ccstatusline_present(path: Path | None = None) -> bool:
|
|
103
|
+
"""ccstatusline is considered present iff its settings.json exists."""
|
|
104
|
+
path = path or default_ccstatusline_path()
|
|
105
|
+
return path.exists()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_zeno_widget(widget: object) -> bool:
|
|
109
|
+
if not isinstance(widget, dict):
|
|
110
|
+
return False
|
|
111
|
+
meta = widget.get("metadata")
|
|
112
|
+
return isinstance(meta, dict) and meta.get(ZENO_META_KEY) == ZENO_META_VALUE
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _zeno_widget(command: str) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"id": CC_WIDGET_ID,
|
|
118
|
+
"type": CC_WIDGET_TYPE,
|
|
119
|
+
"commandPath": command,
|
|
120
|
+
"preserveColors": True, # keep the bar's ANSI colors (no strip)
|
|
121
|
+
"metadata": {ZENO_META_KEY: ZENO_META_VALUE},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _load_ccstatusline(path: Path, stamp: str) -> dict:
|
|
126
|
+
"""Parse the config; on malformed JSON, back it up and refuse to edit (never
|
|
127
|
+
destroy the user's file). Returns {} only when the file is genuinely absent."""
|
|
128
|
+
if not path.exists():
|
|
129
|
+
return {}
|
|
130
|
+
raw = path.read_text(encoding="utf-8")
|
|
131
|
+
if not raw.strip():
|
|
132
|
+
return {}
|
|
133
|
+
try:
|
|
134
|
+
data = json.loads(raw)
|
|
135
|
+
except json.JSONDecodeError as exc:
|
|
136
|
+
_backup(path, stamp) # preserve the malformed file, then refuse
|
|
137
|
+
raise HudInstallError(
|
|
138
|
+
f"{path} is not valid JSON ({exc}). Backed it up and refusing to edit so "
|
|
139
|
+
"your ccstatusline config is never destroyed. Fix or remove it, then retry."
|
|
140
|
+
) from exc
|
|
141
|
+
if not isinstance(data, dict):
|
|
142
|
+
raise HudInstallError(f"{path}: top-level JSON is not an object; refusing to edit.")
|
|
143
|
+
return data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def install_ccstatusline(
|
|
147
|
+
path: Path | None = None,
|
|
148
|
+
*,
|
|
149
|
+
command: str | None = None,
|
|
150
|
+
dry_run: bool = False,
|
|
151
|
+
stamp: str | None = None,
|
|
152
|
+
) -> dict:
|
|
153
|
+
"""Add a dedicated extra line holding the zeno custom-command widget.
|
|
154
|
+
|
|
155
|
+
Read-merge-write: every existing line, widget, and top-level key is preserved;
|
|
156
|
+
we only append one new line ``[<zeno widget>]`` at the end so the cognition bar
|
|
157
|
+
renders as its own row UNDER the user's HUD line(s). Idempotent (detected via the
|
|
158
|
+
zeno metadata flag), backed up, and reversible. Returns a result dict; with
|
|
159
|
+
``dry_run`` nothing is written and the planned config is returned under ``preview``.
|
|
160
|
+
"""
|
|
161
|
+
path = path or default_ccstatusline_path()
|
|
162
|
+
stamp = stamp or _timestamp()
|
|
163
|
+
command = command or resolve_bar_command()
|
|
164
|
+
|
|
165
|
+
if not path.exists():
|
|
166
|
+
return {"action": "not-found", "config": str(path), "command": command, "backup": None}
|
|
167
|
+
|
|
168
|
+
data = _load_ccstatusline(path, stamp)
|
|
169
|
+
lines = data.get("lines", [])
|
|
170
|
+
if not isinstance(lines, list):
|
|
171
|
+
raise HudInstallError(f"{path}: 'lines' is not a list; refusing to edit.")
|
|
172
|
+
|
|
173
|
+
already = any(_is_zeno_widget(w) for line in lines if isinstance(line, list) for w in line)
|
|
174
|
+
action = "unchanged"
|
|
175
|
+
new_data = dict(data)
|
|
176
|
+
if not already:
|
|
177
|
+
new_data["lines"] = list(lines) + [[_zeno_widget(command)]]
|
|
178
|
+
if "version" not in new_data:
|
|
179
|
+
new_data["version"] = CC_SETTINGS_VERSION
|
|
180
|
+
action = "added"
|
|
181
|
+
|
|
182
|
+
if dry_run:
|
|
183
|
+
return {
|
|
184
|
+
"action": action,
|
|
185
|
+
"config": str(path),
|
|
186
|
+
"command": command,
|
|
187
|
+
"backup": None,
|
|
188
|
+
"preview": json.dumps(new_data, indent=2),
|
|
189
|
+
}
|
|
190
|
+
if action == "unchanged":
|
|
191
|
+
return {"action": action, "config": str(path), "command": command, "backup": None}
|
|
192
|
+
|
|
193
|
+
backup = _backup(path, stamp)
|
|
194
|
+
_write_json(path, new_data)
|
|
195
|
+
return {
|
|
196
|
+
"action": action,
|
|
197
|
+
"config": str(path),
|
|
198
|
+
"command": command,
|
|
199
|
+
"backup": str(backup) if backup else None,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def uninstall_ccstatusline(
|
|
204
|
+
path: Path | None = None,
|
|
205
|
+
*,
|
|
206
|
+
restore: bool = False,
|
|
207
|
+
stamp: str | None = None,
|
|
208
|
+
) -> dict:
|
|
209
|
+
"""Remove only the zeno-owned widget/line (or ``restore`` the latest backup
|
|
210
|
+
byte-for-byte). Surgical removal preserves every other line, widget, and key;
|
|
211
|
+
a line that held only the zeno widget is dropped, a line that also held the
|
|
212
|
+
user's widgets keeps them."""
|
|
213
|
+
path = path or default_ccstatusline_path()
|
|
214
|
+
stamp = stamp or _timestamp()
|
|
215
|
+
|
|
216
|
+
if restore:
|
|
217
|
+
latest = _latest_backup(path)
|
|
218
|
+
if latest is None:
|
|
219
|
+
raise HudInstallError(f"no zeno backup found next to {path} to restore.")
|
|
220
|
+
shutil.copy2(latest, path)
|
|
221
|
+
return {"restored_from": str(latest)}
|
|
222
|
+
|
|
223
|
+
if not path.exists():
|
|
224
|
+
return {"removed": False, "config": str(path), "backup": None}
|
|
225
|
+
|
|
226
|
+
data = _load_ccstatusline(path, stamp)
|
|
227
|
+
lines = data.get("lines")
|
|
228
|
+
removed = False
|
|
229
|
+
if isinstance(lines, list):
|
|
230
|
+
new_lines: list = []
|
|
231
|
+
for line in lines:
|
|
232
|
+
if isinstance(line, list):
|
|
233
|
+
kept = [w for w in line if not _is_zeno_widget(w)]
|
|
234
|
+
if len(kept) != len(line):
|
|
235
|
+
removed = True
|
|
236
|
+
if kept:
|
|
237
|
+
new_lines.append(kept)
|
|
238
|
+
continue # drop a line that was zeno-only
|
|
239
|
+
new_lines.append(line)
|
|
240
|
+
# ccstatusline's schema requires at least one line; never leave it empty.
|
|
241
|
+
data["lines"] = new_lines or [[]]
|
|
242
|
+
|
|
243
|
+
if not removed:
|
|
244
|
+
return {"removed": False, "config": str(path), "backup": None}
|
|
245
|
+
backup = _backup(path, stamp)
|
|
246
|
+
_write_json(path, data)
|
|
247
|
+
return {"removed": True, "config": str(path), "backup": str(backup) if backup else None}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# ---------------------------------------------------------------------------
|
|
251
|
+
# claude-hud wrapper adapter
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# The wrapper IS the statusLine: it reads the session JSON from stdin ONCE, feeds it
|
|
254
|
+
# to the real claude-hud (discovered at runtime - newest install, so claude-hud
|
|
255
|
+
# auto-updates keep flowing) and then to zeno-hud-bar, and concatenates the rows.
|
|
256
|
+
# Discovery is version-agnostic (newest mtime match of *claude-hud*/dist/index.js),
|
|
257
|
+
# never a hardcoded version path, mirroring claude-hud's own setup. If claude-hud or
|
|
258
|
+
# node is unavailable the wrapper emits just the zeno bar (no error to stdout).
|
|
259
|
+
_WRAPPER_TEMPLATE = r"""#!/usr/bin/env bash
|
|
260
|
+
# zeno-hud-wrapper - generated by `zeno hud install --target claude-hud`. Do not edit;
|
|
261
|
+
# re-run `zeno hud install` to regenerate. Stacks the zeno cognition bar UNDER the
|
|
262
|
+
# host claude-hud HUD. Reads the session JSON on stdin ONCE and feeds it to both.
|
|
263
|
+
|
|
264
|
+
input="$(cat)"
|
|
265
|
+
|
|
266
|
+
# Discover the newest installed claude-hud dist/index.js (version-agnostic, so
|
|
267
|
+
# claude-hud auto-updates keep flowing). Only if node is on PATH.
|
|
268
|
+
hud_js=""
|
|
269
|
+
if command -v node >/dev/null 2>&1; then
|
|
270
|
+
for root in "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins" "$HOME/.claude/plugins"; do
|
|
271
|
+
[ -d "$root" ] || continue
|
|
272
|
+
matches="$(find "$root" -type f -path '*claude-hud*/dist/index.js' \
|
|
273
|
+
-not -path '*/node_modules/*' 2>/dev/null)"
|
|
274
|
+
if [ -n "$matches" ]; then
|
|
275
|
+
hud_js="$(printf '%s\n' "$matches" | tr '\n' '\0' | xargs -0 ls -t 2>/dev/null | head -n1)"
|
|
276
|
+
[ -n "$hud_js" ] && break
|
|
277
|
+
fi
|
|
278
|
+
done
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
# Line(s) 1..n: the host claude-hud HUD (unchanged, keeps its own updates). Pass
|
|
282
|
+
# COLUMNS through so claude-hud renders at the right width. stderr is suppressed so a
|
|
283
|
+
# claude-hud hiccup never pollutes the statusLine.
|
|
284
|
+
hud_out=""
|
|
285
|
+
if [ -n "$hud_js" ]; then
|
|
286
|
+
hud_out="$(printf '%s' "$input" | COLUMNS="${COLUMNS:-}" node "$hud_js" 2>/dev/null)"
|
|
287
|
+
fi
|
|
288
|
+
|
|
289
|
+
# Final line: the zeno cognition bar (crash-safe; empty line on any error).
|
|
290
|
+
bar_out="$(printf '%s' "$input" | __ZENO_BAR_COMMAND__)"
|
|
291
|
+
|
|
292
|
+
if [ -n "$hud_out" ]; then
|
|
293
|
+
printf '%s\n%s\n' "$hud_out" "$bar_out"
|
|
294
|
+
else
|
|
295
|
+
printf '%s\n' "$bar_out"
|
|
296
|
+
fi
|
|
297
|
+
"""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _zeno_home() -> Path:
|
|
301
|
+
return Path(os.environ.get("ZENO_HOME") or (Path.home() / ".zeno")).expanduser()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def wrapper_path() -> Path:
|
|
305
|
+
"""Where the generated ``zeno-hud-wrapper.sh`` lives (under ZENO_HOME, host-local
|
|
306
|
+
and zeno-owned, so it never collides with the user's own scripts)."""
|
|
307
|
+
return _zeno_home() / "zeno-hud-wrapper.sh"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def find_claude_hud() -> Path | None:
|
|
311
|
+
"""The newest installed claude-hud ``dist/index.js`` (version-agnostic, newest
|
|
312
|
+
mtime), or None. Mirrors the wrapper's runtime discovery so dry-run reports the
|
|
313
|
+
exact file the wrapper will run. Never a hardcoded version path."""
|
|
314
|
+
roots: list[Path] = []
|
|
315
|
+
ccd = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
316
|
+
if ccd:
|
|
317
|
+
roots.append(Path(ccd).expanduser() / "plugins")
|
|
318
|
+
roots.append(Path.home() / ".claude" / "plugins")
|
|
319
|
+
cands: list[Path] = []
|
|
320
|
+
seen: set[str] = set()
|
|
321
|
+
for root in roots:
|
|
322
|
+
key = str(root)
|
|
323
|
+
if key in seen or not root.exists():
|
|
324
|
+
continue
|
|
325
|
+
seen.add(key)
|
|
326
|
+
for p in root.rglob("dist/index.js"):
|
|
327
|
+
sp = str(p)
|
|
328
|
+
# match claude-hud's OWN entry, never a bundled dep's dist/index.js
|
|
329
|
+
# (e.g. .../claude-hud/<ver>/node_modules/escalade/dist/index.js).
|
|
330
|
+
if "claude-hud" in sp and "node_modules" not in sp:
|
|
331
|
+
cands.append(p)
|
|
332
|
+
if not cands:
|
|
333
|
+
return None
|
|
334
|
+
return max(cands, key=lambda p: p.stat().st_mtime)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def claude_hud_present() -> bool:
|
|
338
|
+
"""claude-hud is present iff its executable can be discovered."""
|
|
339
|
+
return find_claude_hud() is not None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def build_wrapper_script(bar_command: str) -> str:
|
|
343
|
+
"""Render the wrapper shell script with the resolved bar command baked in."""
|
|
344
|
+
return _WRAPPER_TEMPLATE.replace("__ZENO_BAR_COMMAND__", bar_command)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def write_wrapper(bar_command: str | None = None) -> Path:
|
|
348
|
+
"""Write (or refresh) the wrapper script and mark it executable. Returns its path."""
|
|
349
|
+
bar_command = bar_command or resolve_bar_command()
|
|
350
|
+
path = wrapper_path()
|
|
351
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
tmp = path.with_name(f"{path.name}.zeno-tmp.{os.getpid()}")
|
|
353
|
+
tmp.write_text(build_wrapper_script(bar_command), encoding="utf-8")
|
|
354
|
+
os.replace(tmp, path)
|
|
355
|
+
path.chmod(0o755)
|
|
356
|
+
return path
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def set_statusline(
|
|
360
|
+
settings_path: Path,
|
|
361
|
+
command: str,
|
|
362
|
+
*,
|
|
363
|
+
force: bool = False,
|
|
364
|
+
stamp: str | None = None,
|
|
365
|
+
) -> dict:
|
|
366
|
+
"""Set settings.json ``statusLine`` to ``command``, reusing the hook_install
|
|
367
|
+
machinery (ZENO_MARKER, backup, zeno-owned detection, ``--force`` semantics) but
|
|
368
|
+
WITHOUT touching the capture hook (decision 3: leave the cc-bridge hook alone).
|
|
369
|
+
|
|
370
|
+
An existing NON-zeno statusLine is kept unless ``force`` (statusLine allows one
|
|
371
|
+
value); a zeno-owned one is always replaced (idempotent re-apply)."""
|
|
372
|
+
from .. import hook_install as HI # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
stamp = stamp or _timestamp()
|
|
375
|
+
data = HI._load_settings(settings_path)
|
|
376
|
+
existing = data.get("statusLine")
|
|
377
|
+
if existing and not HI._is_zeno_handler(existing) and not force:
|
|
378
|
+
return {"statusline": "skipped-existing", "backup": None, "command": command}
|
|
379
|
+
data["statusLine"] = {"type": "command", "command": f"{command} # {HI.ZENO_MARKER}"}
|
|
380
|
+
backup = HI._backup(settings_path, stamp)
|
|
381
|
+
HI._write(settings_path, data)
|
|
382
|
+
return {
|
|
383
|
+
"statusline": "set",
|
|
384
|
+
"backup": str(backup) if backup else None,
|
|
385
|
+
"command": command,
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def remove_statusline(
|
|
390
|
+
settings_path: Path,
|
|
391
|
+
*,
|
|
392
|
+
restore: bool = False,
|
|
393
|
+
stamp: str | None = None,
|
|
394
|
+
) -> dict:
|
|
395
|
+
"""Remove only a zeno-owned ``statusLine`` (or ``restore`` the latest backup
|
|
396
|
+
byte-for-byte). Never touches the capture hook."""
|
|
397
|
+
from .. import hook_install as HI # noqa: PLC0415
|
|
398
|
+
|
|
399
|
+
stamp = stamp or _timestamp()
|
|
400
|
+
if restore:
|
|
401
|
+
latest = _latest_backup(settings_path)
|
|
402
|
+
if latest is None:
|
|
403
|
+
raise HudInstallError(f"no zeno backup found next to {settings_path} to restore.")
|
|
404
|
+
shutil.copy2(latest, settings_path)
|
|
405
|
+
return {"restored_from": str(latest)}
|
|
406
|
+
|
|
407
|
+
if not settings_path.exists():
|
|
408
|
+
return {"statusline_removed": False, "backup": None}
|
|
409
|
+
data = HI._load_settings(settings_path)
|
|
410
|
+
removed = False
|
|
411
|
+
if HI._is_zeno_handler(data.get("statusLine")):
|
|
412
|
+
data.pop("statusLine", None)
|
|
413
|
+
removed = True
|
|
414
|
+
if not removed:
|
|
415
|
+
return {"statusline_removed": False, "backup": None}
|
|
416
|
+
backup = HI._backup(settings_path, stamp)
|
|
417
|
+
HI._write(settings_path, data)
|
|
418
|
+
return {"statusline_removed": True, "backup": str(backup) if backup else None}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def install_claudehud(
|
|
422
|
+
settings_path: Path,
|
|
423
|
+
*,
|
|
424
|
+
bar_command: str | None = None,
|
|
425
|
+
dry_run: bool = False,
|
|
426
|
+
force: bool = False,
|
|
427
|
+
stamp: str | None = None,
|
|
428
|
+
) -> dict:
|
|
429
|
+
"""Generate the wrapper and point settings.json ``statusLine`` at it.
|
|
430
|
+
|
|
431
|
+
Works whether or not claude-hud is currently installed: the wrapper discovers it
|
|
432
|
+
at runtime and degrades to bar-only if absent (so install is safe to run early).
|
|
433
|
+
``node_missing`` / ``claude_hud_found`` are surfaced for a one-line install note;
|
|
434
|
+
they never affect the statusLine output."""
|
|
435
|
+
bar_command = bar_command or resolve_bar_command()
|
|
436
|
+
stamp = stamp or _timestamp()
|
|
437
|
+
found = find_claude_hud()
|
|
438
|
+
node = shutil.which("node")
|
|
439
|
+
command = str(wrapper_path())
|
|
440
|
+
|
|
441
|
+
if dry_run:
|
|
442
|
+
return {
|
|
443
|
+
"action": "dry-run",
|
|
444
|
+
"wrapper": command,
|
|
445
|
+
"settings_path": str(settings_path),
|
|
446
|
+
"bar_command": bar_command,
|
|
447
|
+
"claude_hud_found": str(found) if found else None,
|
|
448
|
+
"node_missing": node is None,
|
|
449
|
+
"preview": build_wrapper_script(bar_command),
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
write_wrapper(bar_command)
|
|
453
|
+
sl = set_statusline(settings_path, command, force=force, stamp=stamp)
|
|
454
|
+
return {
|
|
455
|
+
"action": "installed" if sl["statusline"] == "set" else sl["statusline"],
|
|
456
|
+
"wrapper": command,
|
|
457
|
+
"settings_path": str(settings_path),
|
|
458
|
+
"bar_command": bar_command,
|
|
459
|
+
"statusline": sl["statusline"],
|
|
460
|
+
"backup": sl["backup"],
|
|
461
|
+
"claude_hud_found": str(found) if found else None,
|
|
462
|
+
"node_missing": node is None,
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def uninstall_claudehud(
|
|
467
|
+
settings_path: Path,
|
|
468
|
+
*,
|
|
469
|
+
restore: bool = False,
|
|
470
|
+
stamp: str | None = None,
|
|
471
|
+
remove_wrapper: bool = True,
|
|
472
|
+
) -> dict:
|
|
473
|
+
"""Remove the zeno statusLine (or ``restore`` the backup) and delete the wrapper."""
|
|
474
|
+
stamp = stamp or _timestamp()
|
|
475
|
+
sl = remove_statusline(settings_path, restore=restore, stamp=stamp)
|
|
476
|
+
wrapper_removed = False
|
|
477
|
+
if remove_wrapper and not restore:
|
|
478
|
+
wp = wrapper_path()
|
|
479
|
+
if wp.exists():
|
|
480
|
+
wp.unlink()
|
|
481
|
+
wrapper_removed = True
|
|
482
|
+
return {**sl, "wrapper_removed": wrapper_removed}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# auto-detect orchestrator (zeno hud install / uninstall / status)
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
def resolve_settings_path(explicit: str | None = None) -> Path:
|
|
489
|
+
"""The settings.json to write the claude-hud statusLine into.
|
|
490
|
+
|
|
491
|
+
Honors an explicit path. Otherwise defaults to ``~/.claude/settings.json`` (via
|
|
492
|
+
hook_install, so CLAUDE_CONFIG_DIR is respected), BUT if that file is a SYMLINK
|
|
493
|
+
(e.g. a shared dotfiles checkout) it redirects to the sibling
|
|
494
|
+
``settings.local.json`` host-local override - so a shared/symlinked config is
|
|
495
|
+
never modified. This is the laptop-safe default the Mini install relies on."""
|
|
496
|
+
if explicit:
|
|
497
|
+
return Path(explicit).expanduser()
|
|
498
|
+
from .. import hook_install as HI # noqa: PLC0415
|
|
499
|
+
|
|
500
|
+
base = HI.default_settings_path()
|
|
501
|
+
if base.is_symlink():
|
|
502
|
+
return base.with_name("settings.local.json")
|
|
503
|
+
return base
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def capture_hook_present(settings_path: str | None = None) -> bool:
|
|
507
|
+
"""True if the zeno capture hook is installed (in settings.json or its sibling
|
|
508
|
+
settings.local.json). The cognition line is read-only, so it shows real att/eff/drv
|
|
509
|
+
only once the cc-bridge hook is writing cognition_samples; the installer warns when
|
|
510
|
+
the hook is absent."""
|
|
511
|
+
from .. import hook_install as HI # noqa: PLC0415
|
|
512
|
+
|
|
513
|
+
candidates: list[Path] = []
|
|
514
|
+
if settings_path:
|
|
515
|
+
candidates.append(Path(settings_path).expanduser())
|
|
516
|
+
base = HI.default_settings_path()
|
|
517
|
+
candidates += [base, base.with_name("settings.local.json")]
|
|
518
|
+
for p in candidates:
|
|
519
|
+
try:
|
|
520
|
+
if p.exists() and HI.status(p)["events_installed"]:
|
|
521
|
+
return True
|
|
522
|
+
except HI.HookInstallError:
|
|
523
|
+
continue
|
|
524
|
+
return False
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def detect_target(*, ccstatusline_path: Path | None = None) -> str:
|
|
528
|
+
"""Auto-detect the adapter: ``ccstatusline`` if its config exists (cleanest, a
|
|
529
|
+
native segment), else ``claude-hud`` if the plugin is installed (a wrapper), else
|
|
530
|
+
``none``. ccstatusline wins when both are present (per the locked decision)."""
|
|
531
|
+
if ccstatusline_present(ccstatusline_path):
|
|
532
|
+
return "ccstatusline"
|
|
533
|
+
if claude_hud_present():
|
|
534
|
+
return "claude-hud"
|
|
535
|
+
return "none"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def detect_status(
|
|
539
|
+
*, ccstatusline_path: Path | None = None, settings_path: str | None = None
|
|
540
|
+
) -> dict:
|
|
541
|
+
"""Report what is present + whether zeno is already wired into each adapter."""
|
|
542
|
+
from .. import hook_install as HI # noqa: PLC0415
|
|
543
|
+
|
|
544
|
+
ccstatusline_path = ccstatusline_path or default_ccstatusline_path()
|
|
545
|
+
sp = resolve_settings_path(settings_path)
|
|
546
|
+
cc_installed = False
|
|
547
|
+
if ccstatusline_path.exists():
|
|
548
|
+
try:
|
|
549
|
+
data = json.loads(ccstatusline_path.read_text(encoding="utf-8") or "{}")
|
|
550
|
+
cc_installed = any(
|
|
551
|
+
_is_zeno_widget(w)
|
|
552
|
+
for line in (data.get("lines") or [])
|
|
553
|
+
if isinstance(line, list)
|
|
554
|
+
for w in line
|
|
555
|
+
)
|
|
556
|
+
except (json.JSONDecodeError, OSError):
|
|
557
|
+
cc_installed = False
|
|
558
|
+
ch_statusline = False
|
|
559
|
+
if sp.exists():
|
|
560
|
+
try:
|
|
561
|
+
ch_statusline = HI._is_zeno_handler(HI._load_settings(sp).get("statusLine"))
|
|
562
|
+
except HI.HookInstallError:
|
|
563
|
+
ch_statusline = False
|
|
564
|
+
return {
|
|
565
|
+
"ccstatusline_present": ccstatusline_present(ccstatusline_path),
|
|
566
|
+
"ccstatusline_config": str(ccstatusline_path),
|
|
567
|
+
"ccstatusline_installed": cc_installed,
|
|
568
|
+
"claude_hud_present": claude_hud_present(),
|
|
569
|
+
"claude_hud_path": (str(find_claude_hud()) if claude_hud_present() else None),
|
|
570
|
+
"settings_path": str(sp),
|
|
571
|
+
"claude_hud_installed": ch_statusline,
|
|
572
|
+
"wrapper_present": wrapper_path().exists(),
|
|
573
|
+
"capture_hook_present": capture_hook_present(settings_path),
|
|
574
|
+
"recommended": detect_target(ccstatusline_path=ccstatusline_path),
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def install(
|
|
579
|
+
target: str = "auto",
|
|
580
|
+
*,
|
|
581
|
+
settings_path: str | None = None,
|
|
582
|
+
ccstatusline_path: Path | None = None,
|
|
583
|
+
bar_command: str | None = None,
|
|
584
|
+
dry_run: bool = False,
|
|
585
|
+
force: bool = False,
|
|
586
|
+
stamp: str | None = None,
|
|
587
|
+
) -> dict:
|
|
588
|
+
"""Pick the adapter (or honor an explicit ``target``) and wire the bar in.
|
|
589
|
+
|
|
590
|
+
``auto`` chooses ccstatusline if configured, else claude-hud if installed, else a
|
|
591
|
+
friendly no-op (``target='none'``). An explicit ``ccstatusline`` target with no
|
|
592
|
+
config is reported as ``action='not-found'`` (the CLI exits non-zero); the
|
|
593
|
+
``claude-hud`` target always succeeds (the wrapper degrades to bar-only)."""
|
|
594
|
+
ccstatusline_path = ccstatusline_path or default_ccstatusline_path()
|
|
595
|
+
stamp = stamp or _timestamp()
|
|
596
|
+
chosen = detect_target(ccstatusline_path=ccstatusline_path) if target == "auto" else target
|
|
597
|
+
|
|
598
|
+
if chosen == "ccstatusline":
|
|
599
|
+
res = install_ccstatusline(
|
|
600
|
+
ccstatusline_path, command=bar_command, dry_run=dry_run, stamp=stamp
|
|
601
|
+
)
|
|
602
|
+
return {"target": "ccstatusline", "auto": target == "auto", **res}
|
|
603
|
+
if chosen == "claude-hud":
|
|
604
|
+
sp = resolve_settings_path(settings_path)
|
|
605
|
+
res = install_claudehud(
|
|
606
|
+
sp, bar_command=bar_command, dry_run=dry_run, force=force, stamp=stamp
|
|
607
|
+
)
|
|
608
|
+
return {
|
|
609
|
+
"target": "claude-hud",
|
|
610
|
+
"auto": target == "auto",
|
|
611
|
+
"settings_resolved": str(sp),
|
|
612
|
+
**res,
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
"target": "none",
|
|
616
|
+
"action": "none",
|
|
617
|
+
"auto": target == "auto",
|
|
618
|
+
"ccstatusline_present": ccstatusline_present(ccstatusline_path),
|
|
619
|
+
"claude_hud_present": claude_hud_present(),
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def uninstall(
|
|
624
|
+
*,
|
|
625
|
+
settings_path: str | None = None,
|
|
626
|
+
ccstatusline_path: Path | None = None,
|
|
627
|
+
restore: bool = False,
|
|
628
|
+
stamp: str | None = None,
|
|
629
|
+
) -> dict:
|
|
630
|
+
"""Remove zeno from BOTH adapters (idempotent + safe regardless of which was
|
|
631
|
+
installed). ``restore`` brings each config back from its latest backup byte-for-
|
|
632
|
+
byte, skipping any adapter that has no zeno backup."""
|
|
633
|
+
ccstatusline_path = ccstatusline_path or default_ccstatusline_path()
|
|
634
|
+
sp = resolve_settings_path(settings_path)
|
|
635
|
+
stamp = stamp or _timestamp()
|
|
636
|
+
if restore:
|
|
637
|
+
cc = (
|
|
638
|
+
uninstall_ccstatusline(ccstatusline_path, restore=True, stamp=stamp)
|
|
639
|
+
if _latest_backup(ccstatusline_path)
|
|
640
|
+
else {"restored_from": None}
|
|
641
|
+
)
|
|
642
|
+
ch = (
|
|
643
|
+
uninstall_claudehud(sp, restore=True, stamp=stamp)
|
|
644
|
+
if _latest_backup(sp)
|
|
645
|
+
else {"restored_from": None, "wrapper_removed": False}
|
|
646
|
+
)
|
|
647
|
+
return {"ccstatusline": cc, "claude_hud": ch, "settings_path": str(sp)}
|
|
648
|
+
return {
|
|
649
|
+
"ccstatusline": uninstall_ccstatusline(ccstatusline_path, stamp=stamp),
|
|
650
|
+
"claude_hud": uninstall_claudehud(sp, stamp=stamp),
|
|
651
|
+
"settings_path": str(sp),
|
|
652
|
+
}
|