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.
Files changed (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. 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
+ }