coding-agent-wrapper 0.1.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.
caw/auth/status.py ADDED
@@ -0,0 +1,241 @@
1
+ """Display status of auth files — symlink state, token expiry, last modified."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+
14
+ from .manifest import Manifest
15
+
16
+ console = Console()
17
+
18
+ AUTH_DIR = Path.home() / ".caw" / "auth"
19
+
20
+
21
+ @dataclass
22
+ class AuthFileStatus:
23
+ """Status of a single managed auth file."""
24
+
25
+ agent: str
26
+ file: str # host_original relative path
27
+ type: str # "credential" or "config"
28
+ strategy: str # "symlink" or "copy"
29
+ symlink_state: str # "linked", "wrong_target", "not_linked", "missing", "n/a"
30
+ exists: bool # whether the canonical file exists in auth dir
31
+ token_expiry: str | None # human-readable token info, or None
32
+
33
+
34
+ def _check_token_expiry(auth_dir: Path, agent_name: str) -> str | None:
35
+ """Check token expiry for known agents. Returns human-readable status or None."""
36
+ try:
37
+ if agent_name == "claude":
38
+ cred_path = auth_dir / "claude" / "credentials.json"
39
+ if cred_path.exists():
40
+ with open(cred_path) as f:
41
+ creds = json.load(f)
42
+ expires_at = creds.get("claudeAiOauth", {}).get("expiresAt")
43
+ if expires_at:
44
+ dt = datetime.fromtimestamp(expires_at / 1000, tz=timezone.utc)
45
+ now = datetime.now(timezone.utc)
46
+ if dt < now:
47
+ delta = now - dt
48
+ return f"EXPIRED ({_format_delta(delta)} ago)"
49
+ else:
50
+ delta = dt - now
51
+ return f"valid ({_format_delta(delta)} remaining)"
52
+ except Exception:
53
+ pass
54
+ return None
55
+
56
+
57
+ def _format_delta(delta) -> str:
58
+ """Format a timedelta to a human-readable string."""
59
+ total_seconds = int(delta.total_seconds())
60
+ if total_seconds < 60:
61
+ return f"{total_seconds}s"
62
+ elif total_seconds < 3600:
63
+ return f"{total_seconds // 60}m"
64
+ elif total_seconds < 86400:
65
+ return f"{total_seconds // 3600}h {(total_seconds % 3600) // 60}m"
66
+ else:
67
+ return f"{total_seconds // 86400}d {(total_seconds % 86400) // 3600}h"
68
+
69
+
70
+ def _format_mtime(path: Path) -> str:
71
+ """Format last modified time of a file."""
72
+ try:
73
+ # Resolve symlinks to get the actual file's mtime
74
+ real_path = path.resolve()
75
+ mtime = real_path.stat().st_mtime
76
+ dt = datetime.fromtimestamp(mtime, tz=timezone.utc)
77
+ now = datetime.now(timezone.utc)
78
+ delta = now - dt
79
+ return f"{_format_delta(delta)} ago"
80
+ except Exception:
81
+ return "unknown"
82
+
83
+
84
+ def get_status(
85
+ agents: list[str] | None = None,
86
+ auth_dir: str | Path | None = None,
87
+ ) -> list[AuthFileStatus]:
88
+ """Return structured status of all managed auth files.
89
+
90
+ Args:
91
+ agents: Agent names to include, or None for all.
92
+ auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
93
+
94
+ Returns:
95
+ List of AuthFileStatus for each managed file.
96
+
97
+ Raises:
98
+ FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
99
+ """
100
+ resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
101
+ manifest_path = resolved_dir / "manifest.json"
102
+ if not manifest_path.exists():
103
+ raise FileNotFoundError(f"No manifest.json found at {manifest_path}")
104
+
105
+ manifest = Manifest.load(manifest_path)
106
+ host_home = Path(manifest.host_home)
107
+ agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
108
+
109
+ results: list[AuthFileStatus] = []
110
+ for agent_name, agent_manifest in manifest.agents.items():
111
+ if agent_name not in agent_names:
112
+ continue
113
+
114
+ token_info = _check_token_expiry(resolved_dir, agent_name)
115
+
116
+ for mf in agent_manifest.files:
117
+ canonical = resolved_dir / mf.src
118
+ original = host_home / mf.host_original
119
+
120
+ # Determine symlink state
121
+ if mf.strategy == "symlink":
122
+ if original.is_symlink():
123
+ if original.resolve() == canonical.resolve():
124
+ symlink_state = "linked"
125
+ else:
126
+ symlink_state = "wrong_target"
127
+ elif original.exists():
128
+ symlink_state = "not_linked"
129
+ else:
130
+ symlink_state = "missing"
131
+ else:
132
+ symlink_state = "n/a"
133
+
134
+ results.append(
135
+ AuthFileStatus(
136
+ agent=agent_name,
137
+ file=mf.host_original,
138
+ type=mf.type,
139
+ strategy=mf.strategy,
140
+ symlink_state=symlink_state,
141
+ exists=canonical.exists(),
142
+ token_expiry=token_info if mf.type == "credential" else None,
143
+ )
144
+ )
145
+
146
+ return results
147
+
148
+
149
+ def get_docker_flags(auth_dir: str | Path | None = None) -> str:
150
+ """Return the Docker ``-v`` flag for mounting the auth directory.
151
+
152
+ Args:
153
+ auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
154
+
155
+ Returns:
156
+ A string like ``-v /path/to/auth:/tmp/caw_auth:rw``.
157
+
158
+ Raises:
159
+ FileNotFoundError: If the manifest.json doesn't exist in auth_dir.
160
+ """
161
+ resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
162
+ manifest_path = resolved_dir / "manifest.json"
163
+ if not manifest_path.exists():
164
+ raise FileNotFoundError(f"No manifest.json found at {manifest_path}")
165
+
166
+ manifest = Manifest.load(manifest_path)
167
+ return f"-v {resolved_dir}:{manifest.mount_point}:rw"
168
+
169
+
170
+ def status(agents: list[str] | None = None, auth_dir: str | Path | None = None) -> None:
171
+ """Show status of all managed auth files.
172
+
173
+ Args:
174
+ agents: Agent names to show, or None for all.
175
+ auth_dir: Custom auth directory. Defaults to ~/.caw/auth/.
176
+ """
177
+ resolved_dir = Path(auth_dir) if auth_dir else AUTH_DIR
178
+ manifest_path = resolved_dir / "manifest.json"
179
+ if not manifest_path.exists():
180
+ console.print("[yellow]No auth directory found.[/yellow] Run `caw auth setup` first.")
181
+ return
182
+
183
+ manifest = Manifest.load(manifest_path)
184
+ host_home = Path(manifest.host_home)
185
+
186
+ agent_names = set(agents) if agents and "all" not in agents else set(manifest.agents.keys())
187
+
188
+ table = Table(title="caw auth status", show_lines=True)
189
+ table.add_column("Agent", style="bold")
190
+ table.add_column("File", style="dim")
191
+ table.add_column("Type")
192
+ table.add_column("Strategy")
193
+ table.add_column("Symlink State")
194
+ table.add_column("Last Modified")
195
+ table.add_column("Token")
196
+
197
+ for agent_name, agent_manifest in manifest.agents.items():
198
+ if agent_name not in agent_names:
199
+ continue
200
+
201
+ token_info = _check_token_expiry(resolved_dir, agent_name)
202
+
203
+ for i, mf in enumerate(agent_manifest.files):
204
+ canonical = resolved_dir / mf.src
205
+ original = host_home / mf.host_original
206
+
207
+ # Check symlink state
208
+ if mf.strategy == "symlink":
209
+ if original.is_symlink():
210
+ target = os.readlink(str(original))
211
+ if original.resolve() == canonical.resolve():
212
+ symlink_state = "[green]linked[/green]"
213
+ else:
214
+ symlink_state = f"[yellow]wrong target[/yellow] ({target})"
215
+ elif original.exists():
216
+ symlink_state = "[yellow]not linked[/yellow] (regular file)"
217
+ else:
218
+ symlink_state = "[red]missing[/red]"
219
+ else:
220
+ symlink_state = "[dim]n/a (copy)[/dim]"
221
+
222
+ # Last modified
223
+ mtime = _format_mtime(canonical) if canonical.exists() else "[red]missing[/red]"
224
+
225
+ # Token info only on first row per agent
226
+ token_col = token_info if (i == 0 and token_info) else ""
227
+
228
+ table.add_row(
229
+ agent_name if i == 0 else "",
230
+ mf.host_original,
231
+ mf.type,
232
+ mf.strategy,
233
+ symlink_state,
234
+ mtime,
235
+ token_col,
236
+ )
237
+
238
+ console.print(table)
239
+
240
+ # Docker flags hint
241
+ console.print(f"\n[dim]Docker mount flag: -v {resolved_dir}:{manifest.mount_point}:rw[/dim]")
caw/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ """caw CLI — main entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ import sys
7
+
8
+ import typer
9
+
10
+ from caw.auth.cli import app as auth_app
11
+
12
+ app = typer.Typer(
13
+ name="caw",
14
+ help="Coding Agent Wrapper — tools for managing coding agents.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ app.add_typer(auth_app, name="auth")
19
+
20
+
21
+ @app.command()
22
+ def viewer(
23
+ host: str = typer.Option("0.0.0.0", "--host", "-h", help="Host to bind to."),
24
+ port: int = typer.Option(0, "--port", "-p", help="Port to bind to (0 = auto)."),
25
+ ):
26
+ """Launch the trajectory viewer web UI."""
27
+ from caw.viewer import start_viewer_server
28
+
29
+ server = start_viewer_server(
30
+ host=host,
31
+ port=port or None,
32
+ )
33
+ typer.echo(f"Trajectory viewer running at {server.url}")
34
+ typer.echo("Press Ctrl+C to stop.")
35
+
36
+ signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
37
+ try:
38
+ signal.pause()
39
+ except KeyboardInterrupt:
40
+ pass
41
+ finally:
42
+ server.stop()
43
+
44
+
45
+ def main():
46
+ app()
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
caw/display.py ADDED
@@ -0,0 +1,223 @@
1
+ """Rich console display for live agent output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ from enum import Enum
9
+
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.text import Text
13
+
14
+ from caw.models import TextBlock, ThinkingBlock, ToolUse, UsageStats
15
+
16
+ LOG_ENV_VAR = "CAW_LOG"
17
+
18
+
19
+ class DisplayMode(str, Enum):
20
+ """Print modes for agent output."""
21
+
22
+ FULL = "full"
23
+ SHORT = "short"
24
+ RESULT = "result"
25
+ OFF = "off"
26
+
27
+
28
+ def _truncate(text: str, max_len: int = 40) -> str:
29
+ """Truncate text to max_len chars, adding ellipsis if needed."""
30
+ text = text.replace("\n", " ").strip()
31
+ if len(text) <= max_len:
32
+ return text
33
+ return text[: max_len - 1] + "…"
34
+
35
+
36
+ def _first_n_lines(text: str, n: int = 3) -> str:
37
+ """Return the first n lines of text."""
38
+ lines = text.splitlines()
39
+ if len(lines) <= n:
40
+ return text
41
+ return "\n".join(lines[:n]) + f"\n… ({len(lines) - n} more lines)"
42
+
43
+
44
+ class Display:
45
+ """Mode-aware console display for agent events.
46
+
47
+ Uses ``rich.text.Text`` objects (not markup strings) to avoid
48
+ escaping issues with model output containing ``[brackets]``.
49
+ """
50
+
51
+ def __init__(self, mode: DisplayMode | str = DisplayMode.SHORT) -> None:
52
+ if isinstance(mode, str):
53
+ mode = DisplayMode(mode)
54
+ self.mode = mode
55
+ self.console = Console()
56
+ self._lock = threading.RLock()
57
+ self._last_result_text: str = ""
58
+ self._pending_text: TextBlock | None = None
59
+
60
+ # ------------------------------------------------------------------
61
+ # Event handlers
62
+ # ------------------------------------------------------------------
63
+
64
+ def on_metadata(self, **kwargs: str) -> None:
65
+ """Print metadata key-value pairs (agent, model, session, etc.)."""
66
+ with self._lock:
67
+ if self.mode == DisplayMode.OFF:
68
+ return
69
+ pairs = [f"{k}={v}" for k, v in kwargs.items() if v]
70
+ if not pairs:
71
+ return
72
+ line = Text()
73
+ line.append("[Metadata] ", style="dim bold")
74
+ line.append(" ".join(pairs), style="dim")
75
+ self.console.print(line)
76
+
77
+ def _flush_pending_text(self, bold: bool = False) -> None:
78
+ """Print any buffered text block. Called with bold=True for the final one."""
79
+ if self._pending_text is None:
80
+ return
81
+ block = self._pending_text
82
+ self._pending_text = None
83
+
84
+ style = "bold" if bold else ""
85
+ line = Text()
86
+ line.append("[Assistant] ", style="bold blue")
87
+ if self.mode == DisplayMode.FULL:
88
+ line.append(block.text, style=style)
89
+ else:
90
+ line.append(_truncate(block.text), style=style)
91
+ self.console.print(line)
92
+
93
+ def on_user_message(self, message: str) -> None:
94
+ """Print the user's message."""
95
+ with self._lock:
96
+ if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
97
+ return
98
+
99
+ line = Text()
100
+ line.append("[User] ", style="bold green")
101
+ if self.mode == DisplayMode.FULL:
102
+ line.append(message, style="bold")
103
+ else:
104
+ line.append(_truncate(message), style="bold")
105
+ self.console.print(line)
106
+
107
+ def on_text(self, block: TextBlock) -> None:
108
+ """Buffer an assistant text block (printed on next event or at turn end)."""
109
+ with self._lock:
110
+ if self.mode == DisplayMode.OFF:
111
+ return
112
+
113
+ if self.mode == DisplayMode.RESULT:
114
+ self._last_result_text = block.text
115
+ return
116
+
117
+ # Flush any previous text block (not the final one, so not bold)
118
+ self._flush_pending_text(bold=False)
119
+ self._pending_text = block
120
+
121
+ def on_thinking(self, block: ThinkingBlock) -> None:
122
+ """Print a thinking block."""
123
+ with self._lock:
124
+ if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
125
+ return
126
+
127
+ line = Text()
128
+ line.append("[Thinking] ", style="dim magenta")
129
+ if self.mode == DisplayMode.FULL:
130
+ line.append(block.text, style="dim")
131
+ else:
132
+ line.append(_truncate(block.text), style="dim")
133
+ self.console.print(line)
134
+
135
+ def on_tool_call(self, block: ToolUse) -> None:
136
+ """Print a tool call (args only — result not yet known)."""
137
+ with self._lock:
138
+ if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
139
+ return
140
+
141
+ self._flush_pending_text(bold=False)
142
+
143
+ line = Text()
144
+ line.append("[Tool] ", style="bold yellow")
145
+ line.append(block.name, style="bold cyan")
146
+ line.append(" ")
147
+
148
+ if self.mode == DisplayMode.FULL:
149
+ args_str = json.dumps(block.arguments, indent=2)
150
+ line.append(args_str, style="dim")
151
+ else:
152
+ args_str = json.dumps(block.arguments, separators=(",", ":"))
153
+ line.append(_truncate(args_str), style="dim")
154
+ self.console.print(line)
155
+
156
+ def on_tool_result(self, block: ToolUse) -> None:
157
+ """Print a tool result (output now available on the block)."""
158
+ with self._lock:
159
+ if self.mode in (DisplayMode.RESULT, DisplayMode.OFF):
160
+ return
161
+
162
+ tag_style = "bold red" if block.is_error else "bold yellow"
163
+ line = Text()
164
+ line.append("[Result] ", style=tag_style)
165
+ line.append(block.name, style="bold cyan")
166
+
167
+ output = block.output
168
+ if output:
169
+ line.append("\n")
170
+ text = self.mode == DisplayMode.FULL and output or _first_n_lines(output)
171
+ # Parse ANSI escapes so colorful tool output keeps its colors;
172
+ # uncolored portions fall back to the dim base style.
173
+ result_text = Text.from_ansi(text, style="dim")
174
+ line.append_text(result_text)
175
+ self.console.print(line)
176
+
177
+ def on_turn_end(self, result: str, usage: UsageStats, duration_ms: int) -> None:
178
+ """Print end-of-turn stats or deferred result text."""
179
+ with self._lock:
180
+ if self.mode == DisplayMode.OFF:
181
+ return
182
+
183
+ if self.mode == DisplayMode.RESULT:
184
+ if self._last_result_text:
185
+ self.console.print(Panel(self._last_result_text, border_style="green", expand=False))
186
+ self._last_result_text = ""
187
+ return
188
+
189
+ # Flush the last text block as bold (it's the final assistant message)
190
+ self._flush_pending_text(bold=True)
191
+
192
+ # Stats as metadata
193
+ tokens = f"{usage.input_tokens}in/{usage.output_tokens}out"
194
+ meta: dict[str, str] = {
195
+ "duration": f"{duration_ms}ms",
196
+ "tokens": tokens,
197
+ }
198
+ if usage.cost_usd:
199
+ meta["cost"] = f"${usage.cost_usd:.4f}"
200
+ self.on_metadata(**meta)
201
+
202
+
203
+ # -- Global display singleton --------------------------------------------------
204
+
205
+ _global_display: Display | None = None
206
+ _global_display_resolved = False
207
+
208
+
209
+ def get_global_display() -> Display | None:
210
+ """Return the global Display. Falls back to CAW_LOG env var on first call."""
211
+ global _global_display, _global_display_resolved
212
+ if _global_display is None and not _global_display_resolved:
213
+ _global_display_resolved = True
214
+ env_mode = os.environ.get(LOG_ENV_VAR, "short")
215
+ _global_display = Display(mode=env_mode)
216
+ return _global_display
217
+
218
+
219
+ def set_global_display(display: Display | None) -> None:
220
+ """Set (or clear) the global Display instance."""
221
+ global _global_display, _global_display_resolved
222
+ _global_display = display
223
+ _global_display_resolved = True