uxon 3.0.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.
uxon/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """uxon — multi-user wrapper for terminal AI coding agent sessions.
2
+
3
+ The single source of truth for the package version. Hatch reads this via
4
+ ``[tool.hatch.version].path`` so wheels, sdists, the in-tree CLI, and
5
+ ``uxon version`` all agree without a separate ``VERSION`` file.
6
+ """
7
+
8
+ __version__ = "3.0.0"
uxon/__main__.py ADDED
@@ -0,0 +1,12 @@
1
+ """Allow ``python -m uxon`` to invoke the CLI entrypoint.
2
+
3
+ The console-script generated from ``[project.scripts]`` calls
4
+ ``uxon.cli:main`` directly; this module exists so the same entrypoint
5
+ works under ``python -m uxon`` (used by the pty test harness and by
6
+ in-tree development without re-installing the script).
7
+ """
8
+
9
+ from uxon.cli import main
10
+
11
+ if __name__ == "__main__":
12
+ raise SystemExit(main())
uxon/agents.py ADDED
@@ -0,0 +1,154 @@
1
+ """Agent catalog: declarative data for claude / codex / cursor-agent.
2
+
3
+ Pure data + small helpers. No textual, no TUI, no cli imports.
4
+ `probe_agents` uses subprocess locally but never at module scope.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import pwd
11
+ import subprocess
12
+ from concurrent.futures import ThreadPoolExecutor
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class PermissionMode:
18
+ id: str # "normal" | "auto" | "yolo"
19
+ label: str # user-facing in TUI
20
+ flags: tuple[str, ...]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AgentSpec:
25
+ id: str # "claude" | "codex" | "cursor"
26
+ binary: str # executable name on PATH
27
+ session_suffix: str # "@<id>"
28
+ permission_modes: tuple[PermissionMode, ...]
29
+ install_hint: str # shown by doctor
30
+
31
+
32
+ CATALOG: dict[str, AgentSpec] = {
33
+ "claude": AgentSpec(
34
+ id="claude",
35
+ binary="claude",
36
+ session_suffix="@claude",
37
+ permission_modes=(
38
+ PermissionMode("normal", "normal", ()),
39
+ PermissionMode("auto", "auto", ("--permission-mode", "auto")),
40
+ PermissionMode("yolo", "yolo (--dsp)", ("--dangerously-skip-permissions",)),
41
+ ),
42
+ install_hint="see https://docs.claude.com/claude-code",
43
+ ),
44
+ "codex": AgentSpec(
45
+ id="codex",
46
+ binary="codex",
47
+ session_suffix="@codex",
48
+ permission_modes=(
49
+ PermissionMode("normal", "normal", ()),
50
+ PermissionMode("auto", "auto (--full-auto)", ("--full-auto",)),
51
+ PermissionMode(
52
+ "yolo",
53
+ "yolo (--dangerously-bypass-approvals-and-sandbox)",
54
+ ("--dangerously-bypass-approvals-and-sandbox",),
55
+ ),
56
+ ),
57
+ install_hint="install: npm i -g @openai/codex",
58
+ ),
59
+ "cursor": AgentSpec(
60
+ id="cursor",
61
+ binary="cursor-agent",
62
+ session_suffix="@cursor",
63
+ permission_modes=(
64
+ PermissionMode("normal", "normal", ()),
65
+ PermissionMode("yolo", "yolo (--yolo)", ("--yolo",)),
66
+ ),
67
+ install_hint="install: curl https://cursor.com/install -fsSL | bash",
68
+ ),
69
+ }
70
+
71
+
72
+ def permission_mode_for(agent: AgentSpec, mode_id: str) -> PermissionMode | None:
73
+ for mode in agent.permission_modes:
74
+ if mode.id == mode_id:
75
+ return mode
76
+ return None
77
+
78
+
79
+ # ── Availability probe ───────────────────────────────────────────────
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class AgentAvailability:
84
+ status: str # "pending" | "ok" | "missing" | "timeout"
85
+ path: str | None = None
86
+ version: str | None = None
87
+ error: str | None = None
88
+
89
+
90
+ PROBE_TIMEOUT_SEC = (
91
+ 10.0 # cursor-agent --version is slow (~5-8s); probe is async + parallel, non-blocking
92
+ )
93
+
94
+
95
+ def _current_user() -> str:
96
+ return pwd.getpwuid(os.getuid()).pw_name
97
+
98
+
99
+ def _build_probe_cmd(binary: str, launch_user: str | None) -> list[str]:
100
+ if launch_user and launch_user != _current_user():
101
+ # Match the login-env semantics that ``command_prefix_for_user``
102
+ # in ``uxon.cli`` uses for the actual launch (``sudo -iu``). The
103
+ # ``-i`` loads the target user's login shell so ``PATH`` picks
104
+ # up npm-global / nvm / ``~/.local/bin`` entries where agents
105
+ # like ``claude`` and ``cursor-agent`` are typically installed.
106
+ # Without ``-i``, sudo's ``secure_path`` hides them and the
107
+ # probe reports "missing" for agents that the launch can
108
+ # actually run.
109
+ return ["sudo", "-niu", launch_user, "--", binary, "--version"]
110
+ return [binary, "--version"]
111
+
112
+
113
+ def _probe_one(binary: str, launch_user: str | None) -> AgentAvailability:
114
+ cmd = _build_probe_cmd(binary, launch_user)
115
+ try:
116
+ cp = subprocess.run(
117
+ cmd,
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=PROBE_TIMEOUT_SEC,
121
+ )
122
+ except FileNotFoundError as exc:
123
+ return AgentAvailability(status="missing", error=str(exc))
124
+ except subprocess.TimeoutExpired:
125
+ return AgentAvailability(status="timeout")
126
+ if cp.returncode != 0:
127
+ err = (cp.stderr or cp.stdout or "").strip()
128
+ return AgentAvailability(status="missing", error=err or f"exit {cp.returncode}")
129
+ version = (cp.stdout or "").strip().splitlines()[0] if cp.stdout else None
130
+ return AgentAvailability(status="ok", version=version)
131
+
132
+
133
+ def probe_agents(
134
+ agent_ids: list[str],
135
+ launch_user: str | None,
136
+ ) -> dict[str, AgentAvailability]:
137
+ """Probe `<binary> --version` for each known agent id, in parallel.
138
+
139
+ Unknown ids are silently dropped. Runs under ``launch_user`` when
140
+ different from the caller, via ``sudo -n -u <user>``.
141
+ """
142
+ valid = [aid for aid in agent_ids if aid in CATALOG]
143
+ if not valid:
144
+ return {}
145
+ results: dict[str, AgentAvailability] = {}
146
+ with ThreadPoolExecutor(max_workers=max(1, len(valid))) as ex:
147
+ futures = {ex.submit(_probe_one, CATALOG[aid].binary, launch_user): aid for aid in valid}
148
+ for fut in futures:
149
+ aid = futures[fut]
150
+ try:
151
+ results[aid] = fut.result()
152
+ except Exception as exc: # pragma: no cover — defensive
153
+ results[aid] = AgentAvailability(status="missing", error=str(exc))
154
+ return results