aline-ai 0.6.3__py3-none-any.whl → 0.6.5__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.
realign/codex_home.py ADDED
@@ -0,0 +1,85 @@
1
+ """Codex home/session path helpers.
2
+
3
+ To guarantee terminal↔session binding even when multiple Codex instances run in the same cwd,
4
+ we can isolate Codex storage per dashboard terminal via the `CODEX_HOME` environment variable.
5
+
6
+ We choose deterministic paths under `~/.aline/` so the watcher (a separate process) can
7
+ derive the owning terminal_id purely from the session file path.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+
17
+ ENV_CODEX_HOME = "CODEX_HOME"
18
+
19
+
20
+ def aline_codex_homes_dir() -> Path:
21
+ override = os.environ.get("ALINE_CODEX_HOMES_DIR", "").strip()
22
+ if override:
23
+ return Path(os.path.expanduser(override))
24
+ return Path.home() / ".aline" / "codex_homes"
25
+
26
+
27
+ def codex_home_for_terminal(terminal_id: str) -> Path:
28
+ tid = (terminal_id or "").strip()
29
+ return aline_codex_homes_dir() / tid
30
+
31
+
32
+ def codex_sessions_dir_for_home(codex_home: Path) -> Path:
33
+ return codex_home / "sessions"
34
+
35
+
36
+ def codex_sessions_dir_for_terminal(terminal_id: str) -> Path:
37
+ return codex_sessions_dir_for_home(codex_home_for_terminal(terminal_id))
38
+
39
+
40
+ def terminal_id_from_codex_session_file(session_file: Path) -> Optional[str]:
41
+ """If session_file is under an Aline-managed CODEX_HOME, return terminal_id."""
42
+ try:
43
+ homes = aline_codex_homes_dir().resolve()
44
+ p = session_file.resolve()
45
+ except Exception:
46
+ return None
47
+
48
+ try:
49
+ rel = p.relative_to(homes)
50
+ except ValueError:
51
+ return None
52
+
53
+ parts = rel.parts
54
+ if len(parts) < 3:
55
+ return None
56
+ terminal_id = (parts[0] or "").strip()
57
+ if not terminal_id:
58
+ return None
59
+ if parts[1] != "sessions":
60
+ return None
61
+ return terminal_id
62
+
63
+
64
+ def prepare_codex_home(terminal_id: str) -> Path:
65
+ """Create/prepare an isolated CODEX_HOME for a terminal (best-effort)."""
66
+ home = codex_home_for_terminal(terminal_id)
67
+ sessions = codex_sessions_dir_for_home(home)
68
+ try:
69
+ sessions.mkdir(parents=True, exist_ok=True)
70
+ except Exception:
71
+ pass
72
+
73
+ # Keep Codex skills working under the isolated home by symlinking to the global skills dir.
74
+ try:
75
+ global_skills = Path.home() / ".codex" / "skills"
76
+ if global_skills.exists():
77
+ skills_link = home / "skills"
78
+ if not skills_link.exists():
79
+ skills_link.parent.mkdir(parents=True, exist_ok=True)
80
+ skills_link.symlink_to(global_skills)
81
+ except Exception:
82
+ pass
83
+
84
+ return home
85
+
@@ -0,0 +1,172 @@
1
+ """Best-effort linking between Codex session files and Aline terminals.
2
+
3
+ Claude Code provides explicit hook callbacks with session identifiers. Codex CLI does not,
4
+ so we infer the binding by matching:
5
+ - session_meta.cwd (project/workspace path)
6
+ - session creation time vs. terminal creation time
7
+
8
+ This module is intentionally dependency-light so it can be used by the watcher.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from dataclasses import dataclass
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Iterable, Optional, Protocol
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class CodexSessionMeta:
22
+ session_file: Path
23
+ cwd: str
24
+ started_at: Optional[datetime] = None
25
+ originator: Optional[str] = None
26
+ source: Optional[str] = None
27
+
28
+
29
+ def _parse_iso8601(ts: str) -> Optional[datetime]:
30
+ raw = (ts or "").strip()
31
+ if not raw:
32
+ return None
33
+ # Common Codex format: 2025-12-23T09:14:28.152Z
34
+ if raw.endswith("Z"):
35
+ raw = raw[:-1] + "+00:00"
36
+ try:
37
+ dt = datetime.fromisoformat(raw)
38
+ except ValueError:
39
+ return None
40
+ if dt.tzinfo is None:
41
+ dt = dt.replace(tzinfo=timezone.utc)
42
+ return dt
43
+
44
+
45
+ def read_codex_session_meta(session_file: Path) -> Optional[CodexSessionMeta]:
46
+ """Extract Codex session metadata from a session file (best-effort)."""
47
+ try:
48
+ with session_file.open("r", encoding="utf-8") as f:
49
+ for i, line in enumerate(f):
50
+ if i >= 25:
51
+ break
52
+ raw = (line or "").strip()
53
+ if not raw:
54
+ continue
55
+ try:
56
+ data = json.loads(raw)
57
+ except json.JSONDecodeError:
58
+ continue
59
+
60
+ # Typical format: {"type":"session_meta","payload":{...}}
61
+ if data.get("type") == "session_meta":
62
+ payload = data.get("payload") or {}
63
+ cwd = str(payload.get("cwd") or "").strip()
64
+ if not cwd:
65
+ return None
66
+ started_at = _parse_iso8601(str(payload.get("timestamp") or ""))
67
+ originator = str(payload.get("originator") or "").strip() or None
68
+ source = str(payload.get("source") or "").strip() or None
69
+ return CodexSessionMeta(
70
+ session_file=session_file,
71
+ cwd=cwd,
72
+ started_at=started_at,
73
+ originator=originator,
74
+ source=source,
75
+ )
76
+
77
+ # Newer Codex header: first line may have {id, timestamp, git} without "type"
78
+ if i == 0 and "timestamp" in data and "type" not in data:
79
+ started_at = _parse_iso8601(str(data.get("timestamp") or ""))
80
+ git = data.get("git") if isinstance(data.get("git"), dict) else {}
81
+ cwd = ""
82
+ if isinstance(git, dict):
83
+ cwd = str(git.get("cwd") or "").strip()
84
+ if not cwd:
85
+ cwd = str(data.get("cwd") or "").strip()
86
+ if not cwd:
87
+ return None
88
+ return CodexSessionMeta(session_file=session_file, cwd=cwd, started_at=started_at)
89
+ except OSError:
90
+ return None
91
+ except Exception:
92
+ return None
93
+
94
+ return None
95
+
96
+
97
+ class _AgentLike(Protocol):
98
+ id: str
99
+ provider: str
100
+ status: str
101
+ cwd: Optional[str]
102
+ session_id: Optional[str]
103
+ transcript_path: Optional[str]
104
+ created_at: datetime
105
+
106
+
107
+ def select_agent_for_codex_session(
108
+ agents: Iterable[_AgentLike],
109
+ *,
110
+ session: CodexSessionMeta,
111
+ max_time_delta_seconds: int = 6 * 60 * 60,
112
+ ) -> Optional[str]:
113
+ """Pick the best active Codex terminal for a Codex session file (best-effort)."""
114
+ cwd = (session.cwd or "").strip()
115
+ if not cwd:
116
+ return None
117
+
118
+ candidates: list[_AgentLike] = []
119
+ for a in agents:
120
+ try:
121
+ if getattr(a, "status", "") != "active":
122
+ continue
123
+ if getattr(a, "provider", "") != "codex":
124
+ continue
125
+ if (getattr(a, "cwd", None) or "").strip() != cwd:
126
+ continue
127
+ # Avoid clobbering an existing binding to a different session.
128
+ existing_sid = (getattr(a, "session_id", None) or "").strip()
129
+ if existing_sid and existing_sid != session.session_file.stem:
130
+ continue
131
+ existing_path = (getattr(a, "transcript_path", None) or "").strip()
132
+ if existing_path and existing_path != str(session.session_file):
133
+ continue
134
+ candidates.append(a)
135
+ except Exception:
136
+ continue
137
+
138
+ if not candidates:
139
+ return None
140
+ if len(candidates) == 1:
141
+ return candidates[0].id
142
+
143
+ # Pick closest by creation time.
144
+ if session.started_at is not None:
145
+ ref = session.started_at
146
+ else:
147
+ try:
148
+ ref = datetime.fromtimestamp(session.session_file.stat().st_mtime, tz=timezone.utc)
149
+ except OSError:
150
+ ref = datetime.now(tz=timezone.utc)
151
+
152
+ best_id: Optional[str] = None
153
+ best_delta: Optional[float] = None
154
+ for a in candidates:
155
+ try:
156
+ created_at = a.created_at
157
+ if created_at.tzinfo is None:
158
+ created_at = created_at.replace(tzinfo=timezone.utc)
159
+ delta = abs((ref - created_at).total_seconds())
160
+ if best_delta is None or delta < best_delta:
161
+ best_delta = delta
162
+ best_id = a.id
163
+ except Exception:
164
+ continue
165
+
166
+ if best_id is None:
167
+ return None
168
+ if best_delta is not None and best_delta > max_time_delta_seconds:
169
+ # Ambiguous: don't bind if terminals are too far from the session start.
170
+ return None
171
+ return best_id
172
+
@@ -1,5 +1,5 @@
1
1
  """ReAlign commands module."""
2
2
 
3
- from . import init, config
3
+ from . import init, config, doctor
4
4
 
5
- __all__ = ["init", "config"]
5
+ __all__ = ["init", "config", "doctor"]
realign/commands/add.py CHANGED
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import shutil
6
7
  import subprocess
8
+ import sys
7
9
  from pathlib import Path
8
10
 
9
11
  from rich.console import Console
@@ -497,12 +499,60 @@ def _source_aline_tmux_conf(tmux_conf: Path) -> None:
497
499
  continue
498
500
 
499
501
 
500
- def add_tmux_command() -> int:
501
- """Install tmux (via Homebrew) and set up Aline's tmux config."""
502
+ def _find_brew() -> str | None:
502
503
  brew = shutil.which("brew")
504
+ return brew
505
+
506
+
507
+ def _prompt_install_homebrew() -> bool:
508
+ if not sys.stdin.isatty():
509
+ console.print("[red]Homebrew not found.[/red]")
510
+ console.print("[dim]Install from https://brew.sh and retry.[/dim]")
511
+ return False
512
+
513
+ console.print("[yellow]Homebrew not found.[/yellow]")
514
+ console.print("[dim]Aline can install tmux automatically via Homebrew.[/dim]\n")
515
+ try:
516
+ answer = console.input("Install Homebrew now? ([green]y[/green]/[yellow]n[/yellow]): ").strip()
517
+ except (EOFError, KeyboardInterrupt):
518
+ return False
519
+ if answer.lower() not in ("y", "yes"):
520
+ console.print("[dim]Skipped Homebrew install.[/dim]")
521
+ console.print("[dim]Install from https://brew.sh and retry.[/dim]")
522
+ return False
523
+
524
+ console.print("\n[bold]Installing Homebrew (official script)...[/bold]")
525
+ console.print(
526
+ "[dim]Tip: if this fails, follow the manual steps at https://brew.sh[/dim]\n"
527
+ )
528
+ # Official install script (see https://brew.sh).
529
+ proc = subprocess.run(
530
+ [
531
+ "/bin/bash",
532
+ "-c",
533
+ "curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh | /bin/bash",
534
+ ],
535
+ text=True,
536
+ capture_output=False,
537
+ check=False,
538
+ )
539
+ if proc.returncode != 0:
540
+ console.print(f"[red]Failed:[/red] Homebrew install (exit {proc.returncode})")
541
+ return False
542
+ return True
543
+
544
+
545
+ def add_tmux_command(*, install_brew: bool = False) -> int:
546
+ """Install tmux (via Homebrew) and set up Aline's tmux config."""
547
+ brew = _find_brew()
503
548
  if brew is None:
504
- console.print("[red]Homebrew not found.[/red] Install from https://brew.sh and retry.")
505
- return 1
549
+ if sys.platform == "darwin" and install_brew:
550
+ if not _prompt_install_homebrew():
551
+ return 1
552
+ brew = _find_brew()
553
+ if brew is None:
554
+ console.print("[red]Homebrew not found.[/red] Install from https://brew.sh and retry.")
555
+ return 1
506
556
 
507
557
  console.print("[dim]Running: brew install tmux[/dim]")
508
558
  proc = _run([brew, "install", "tmux"])
@@ -554,6 +604,25 @@ def _ensure_symlink(target_link: Path, source_file: Path, force: bool = False) -
554
604
  return True
555
605
 
556
606
 
607
+ def _ensure_copy(target_file: Path, source_file: Path, force: bool = False) -> bool:
608
+ """Copy source_file to target_file.
609
+
610
+ Returns:
611
+ True if target was created/updated, False if skipped.
612
+ """
613
+ if target_file.exists() or target_file.is_symlink():
614
+ if not force:
615
+ return False
616
+ if target_file.is_dir() and not target_file.is_symlink():
617
+ shutil.rmtree(target_file)
618
+ else:
619
+ target_file.unlink()
620
+
621
+ target_file.parent.mkdir(parents=True, exist_ok=True)
622
+ shutil.copy2(source_file, target_file)
623
+ return True
624
+
625
+
557
626
  def add_skills_command(force: bool = False) -> int:
558
627
  """Install Aline skills for Claude Code and Codex.
559
628
 
@@ -567,10 +636,18 @@ def add_skills_command(force: bool = False) -> int:
567
636
  Exit code (0 for success, 1 for failure)
568
637
  """
569
638
  aline_skill_root = Path.home() / ".aline" / "skills"
639
+ codex_home_env = os.environ.get("CODEX_HOME", "").strip()
640
+ codex_home = Path.home() / ".codex"
641
+ if codex_home_env:
642
+ env_path = Path(codex_home_env).expanduser()
643
+ # Avoid installing into per-terminal isolated CODEX_HOME dirs.
644
+ if ".aline/codex_homes" not in str(env_path):
645
+ codex_home = env_path
570
646
  targets = [
571
- ("Claude", Path.home() / ".claude" / "skills"),
572
- ("Codex", Path.home() / ".codex" / "skills"),
573
- ("OpenCode", Path.home() / ".config" / "opencode" / "skill"),
647
+ ("Claude", Path.home() / ".claude" / "skills", "symlink"),
648
+ # Codex skills are safer as real files: some environments/tools ignore symlinks.
649
+ ("Codex", codex_home / "skills", "copy"),
650
+ ("OpenCode", Path.home() / ".config" / "opencode" / "skill", "symlink"),
574
651
  ]
575
652
 
576
653
  installed_skills: list[str] = []
@@ -588,11 +665,14 @@ def add_skills_command(force: bool = False) -> int:
588
665
  continue
589
666
 
590
667
  # 2. Link to targets
591
- for tool_name, tool_root in targets:
668
+ for tool_name, tool_root, mode in targets:
592
669
  dest_path = tool_root / skill_name / "SKILL.md"
593
670
 
594
671
  try:
595
- updated = _ensure_symlink(dest_path, master_path, force)
672
+ if mode == "copy":
673
+ updated = _ensure_copy(dest_path, master_path, force)
674
+ else:
675
+ updated = _ensure_symlink(dest_path, master_path, force)
596
676
  if updated:
597
677
  installed_skills.append(f"{tool_name}/{skill_name}")
598
678
  else: