axion-code 1.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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/git.py ADDED
@@ -0,0 +1,213 @@
1
+ """Git workflow operations.
2
+
3
+ Maps to: rust/crates/runtime/src/git.rs
4
+
5
+ Provides typed wrappers around common git commands using subprocess.run
6
+ with proper error handling.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class GitError(Exception):
20
+ """Error from a git operation."""
21
+
22
+ def __init__(self, message: str, *, returncode: int = 1) -> None:
23
+ super().__init__(message)
24
+ self.returncode = returncode
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Data structures
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @dataclass
33
+ class GitStatus:
34
+ """Parsed output of ``git status``."""
35
+
36
+ branch: str
37
+ clean: bool
38
+ staged: int = 0
39
+ modified: int = 0
40
+ untracked: int = 0
41
+
42
+
43
+ @dataclass
44
+ class GitCommit:
45
+ """A single commit from ``git log``."""
46
+
47
+ hash: str
48
+ author: str
49
+ date: str
50
+ message: str
51
+
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Helpers
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ def _run(
59
+ args: list[str],
60
+ cwd: Path,
61
+ *,
62
+ check: bool = True,
63
+ ) -> subprocess.CompletedProcess[str]:
64
+ """Run a git command with standard options."""
65
+ try:
66
+ result = subprocess.run(
67
+ args,
68
+ cwd=str(cwd),
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=30,
72
+ )
73
+ except subprocess.TimeoutExpired as exc:
74
+ raise GitError(f"Git command timed out: {' '.join(args)}") from exc
75
+ except FileNotFoundError as exc:
76
+ raise GitError("git executable not found") from exc
77
+
78
+ if check and result.returncode != 0:
79
+ stderr = result.stderr.strip()
80
+ raise GitError(
81
+ f"git {args[1] if len(args) > 1 else ''} failed: {stderr}",
82
+ returncode=result.returncode,
83
+ )
84
+ return result
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Public API
89
+ # ---------------------------------------------------------------------------
90
+
91
+
92
+ def git_status(cwd: Path) -> GitStatus:
93
+ """Return parsed status (branch, clean/dirty, file counts)."""
94
+ # Branch name
95
+ branch_result = _run(
96
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd, check=False
97
+ )
98
+ branch = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
99
+
100
+ # Porcelain status for counts
101
+ result = _run(["git", "status", "--porcelain"], cwd)
102
+ lines = [line for line in result.stdout.splitlines() if line.strip()]
103
+
104
+ staged = 0
105
+ modified = 0
106
+ untracked = 0
107
+
108
+ for line in lines:
109
+ if len(line) < 2:
110
+ continue
111
+ x, y = line[0], line[1]
112
+ if x == "?":
113
+ untracked += 1
114
+ else:
115
+ if x in "ACDMR":
116
+ staged += 1
117
+ if y in "ACDMR":
118
+ modified += 1
119
+
120
+ return GitStatus(
121
+ branch=branch,
122
+ clean=len(lines) == 0,
123
+ staged=staged,
124
+ modified=modified,
125
+ untracked=untracked,
126
+ )
127
+
128
+
129
+ def git_log(cwd: Path, n: int = 10) -> list[GitCommit]:
130
+ """Return recent commits."""
131
+ result = _run(
132
+ [
133
+ "git",
134
+ "log",
135
+ f"-{n}",
136
+ "--format=%H%n%an%n%ai%n%s%n---",
137
+ ],
138
+ cwd,
139
+ )
140
+
141
+ commits: list[GitCommit] = []
142
+ entries = result.stdout.strip().split("---\n")
143
+ for entry in entries:
144
+ entry = entry.strip()
145
+ if not entry:
146
+ continue
147
+ parts = entry.split("\n", 3)
148
+ if len(parts) >= 4:
149
+ commits.append(
150
+ GitCommit(
151
+ hash=parts[0].strip(),
152
+ author=parts[1].strip(),
153
+ date=parts[2].strip(),
154
+ message=parts[3].strip(),
155
+ )
156
+ )
157
+ elif len(parts) == 3:
158
+ commits.append(
159
+ GitCommit(
160
+ hash=parts[0].strip(),
161
+ author=parts[1].strip(),
162
+ date=parts[2].strip(),
163
+ message="",
164
+ )
165
+ )
166
+
167
+ return commits
168
+
169
+
170
+ def git_diff(cwd: Path, staged: bool = False) -> str:
171
+ """Return diff output."""
172
+ args = ["git", "diff"]
173
+ if staged:
174
+ args.append("--staged")
175
+ result = _run(args, cwd)
176
+ return result.stdout
177
+
178
+
179
+ def git_branch(cwd: Path) -> str:
180
+ """Return current branch name."""
181
+ result = _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd)
182
+ return result.stdout.strip()
183
+
184
+
185
+ def git_commit(cwd: Path, message: str, files: list[str]) -> str:
186
+ """Stage specific files and commit."""
187
+ if not files:
188
+ raise GitError("No files specified for commit")
189
+
190
+ # Stage files
191
+ _run(["git", "add", "--"] + files, cwd)
192
+
193
+ # Commit
194
+ result = _run(["git", "commit", "-m", message], cwd)
195
+ return result.stdout.strip()
196
+
197
+
198
+ def git_create_branch(cwd: Path, name: str) -> str:
199
+ """Create and checkout a new branch."""
200
+ result = _run(["git", "checkout", "-b", name], cwd)
201
+ return result.stdout.strip() or result.stderr.strip()
202
+
203
+
204
+ def git_stash(cwd: Path) -> str:
205
+ """Stash current changes."""
206
+ result = _run(["git", "stash"], cwd)
207
+ return result.stdout.strip()
208
+
209
+
210
+ def git_stash_pop(cwd: Path) -> str:
211
+ """Pop the most recent stash."""
212
+ result = _run(["git", "stash", "pop"], cwd)
213
+ return result.stdout.strip()
axion/runtime/hooks.py ADDED
@@ -0,0 +1,235 @@
1
+ """Hook system for pre/post tool execution.
2
+
3
+ Maps to: rust/crates/runtime/src/hooks.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import enum
10
+ import json
11
+ import logging
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Protocol, runtime_checkable
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class HookEvent(enum.Enum):
20
+ PRE_TOOL_USE = "pre_tool_use"
21
+ POST_TOOL_USE = "post_tool_use"
22
+ POST_TOOL_USE_FAILURE = "post_tool_use_failure"
23
+
24
+
25
+ @dataclass
26
+ class HookRunResult:
27
+ """Result of running hooks for a single event."""
28
+
29
+ denied: bool = False
30
+ failed: bool = False
31
+ cancelled: bool = False
32
+ messages: list[str] = field(default_factory=list)
33
+ permission_override: str | None = None
34
+ permission_reason: str | None = None
35
+ updated_input: str | None = None
36
+
37
+
38
+ @dataclass
39
+ class HookConfig:
40
+ """Configuration for a single hook."""
41
+
42
+ command: str
43
+ timeout_ms: int = 10_000
44
+
45
+
46
+ @runtime_checkable
47
+ class HookProgressReporter(Protocol):
48
+ """Protocol for hook progress reporting."""
49
+
50
+ def on_hook_started(self, event: HookEvent, tool_name: str, command: str) -> None: ...
51
+ def on_hook_completed(self, event: HookEvent, tool_name: str, command: str) -> None: ...
52
+
53
+
54
+ class HookRunner:
55
+ """Executes hooks as subprocesses.
56
+
57
+ Maps to: rust/crates/runtime/src/hooks.rs::HookRunner
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ pre_tool_use: list[HookConfig] | None = None,
63
+ post_tool_use: list[HookConfig] | None = None,
64
+ post_tool_use_failure: list[HookConfig] | None = None,
65
+ progress_reporter: HookProgressReporter | None = None,
66
+ ) -> None:
67
+ self.pre_tool_use = pre_tool_use or []
68
+ self.post_tool_use = post_tool_use or []
69
+ self.post_tool_use_failure = post_tool_use_failure or []
70
+ self.progress_reporter = progress_reporter
71
+
72
+ @classmethod
73
+ def from_config(cls, hooks_data: dict[str, Any]) -> HookRunner:
74
+ """Create HookRunner from configuration dict."""
75
+ def parse_hooks(data: list[Any]) -> list[HookConfig]:
76
+ configs = []
77
+ for item in data:
78
+ if isinstance(item, str):
79
+ configs.append(HookConfig(command=item))
80
+ elif isinstance(item, dict):
81
+ configs.append(HookConfig(
82
+ command=item.get("command", ""),
83
+ timeout_ms=item.get("timeout_ms", 10_000),
84
+ ))
85
+ return configs
86
+
87
+ return cls(
88
+ pre_tool_use=parse_hooks(hooks_data.get("preToolUse", [])),
89
+ post_tool_use=parse_hooks(hooks_data.get("postToolUse", [])),
90
+ post_tool_use_failure=parse_hooks(hooks_data.get("postToolUseFailure", [])),
91
+ )
92
+
93
+ async def run_pre_tool_use(
94
+ self, tool_name: str, tool_input: str
95
+ ) -> HookRunResult:
96
+ """Run pre-tool-use hooks. Returns deny if any hook exits with code 2."""
97
+ return await self._run_hooks(
98
+ HookEvent.PRE_TOOL_USE,
99
+ self.pre_tool_use,
100
+ tool_name,
101
+ tool_input,
102
+ )
103
+
104
+ async def run_post_tool_use(
105
+ self, tool_name: str, tool_input: str, tool_output: str, is_error: bool
106
+ ) -> HookRunResult:
107
+ """Run post-tool-use hooks."""
108
+ return await self._run_hooks(
109
+ HookEvent.POST_TOOL_USE,
110
+ self.post_tool_use,
111
+ tool_name,
112
+ tool_input,
113
+ tool_output=tool_output,
114
+ is_error=is_error,
115
+ )
116
+
117
+ async def run_post_tool_use_failure(
118
+ self, tool_name: str, tool_input: str, error: str
119
+ ) -> HookRunResult:
120
+ """Run post-tool-use-failure hooks."""
121
+ return await self._run_hooks(
122
+ HookEvent.POST_TOOL_USE_FAILURE,
123
+ self.post_tool_use_failure,
124
+ tool_name,
125
+ tool_input,
126
+ tool_output=error,
127
+ is_error=True,
128
+ )
129
+
130
+ async def _run_hooks(
131
+ self,
132
+ event: HookEvent,
133
+ hooks: list[HookConfig],
134
+ tool_name: str,
135
+ tool_input: str,
136
+ tool_output: str = "",
137
+ is_error: bool = False,
138
+ ) -> HookRunResult:
139
+ """Run a list of hooks sequentially."""
140
+ result = HookRunResult()
141
+
142
+ for hook in hooks:
143
+ if self.progress_reporter:
144
+ self.progress_reporter.on_hook_started(event, tool_name, hook.command)
145
+
146
+ try:
147
+ hook_result = await self._execute_hook(
148
+ hook, event, tool_name, tool_input, tool_output, is_error
149
+ )
150
+ except Exception as exc:
151
+ logger.error("Hook '%s' failed: %s", hook.command, exc)
152
+ result.failed = True
153
+ result.messages.append(f"Hook error: {exc}")
154
+ continue
155
+
156
+ if self.progress_reporter:
157
+ self.progress_reporter.on_hook_completed(event, tool_name, hook.command)
158
+
159
+ if hook_result.denied:
160
+ return hook_result
161
+ if hook_result.messages:
162
+ result.messages.extend(hook_result.messages)
163
+ if hook_result.updated_input:
164
+ result.updated_input = hook_result.updated_input
165
+
166
+ return result
167
+
168
+ async def _execute_hook(
169
+ self,
170
+ hook: HookConfig,
171
+ event: HookEvent,
172
+ tool_name: str,
173
+ tool_input: str,
174
+ tool_output: str,
175
+ is_error: bool,
176
+ ) -> HookRunResult:
177
+ """Execute a single hook as a subprocess."""
178
+ # Build environment
179
+ env = {
180
+ **os.environ,
181
+ "HOOK_EVENT": event.value,
182
+ "HOOK_TOOL_NAME": tool_name,
183
+ "HOOK_TOOL_INPUT": tool_input,
184
+ "HOOK_TOOL_OUTPUT": tool_output,
185
+ "HOOK_TOOL_IS_ERROR": str(is_error).lower(),
186
+ }
187
+
188
+ # Build payload
189
+ payload = json.dumps({
190
+ "event": event.value,
191
+ "tool_name": tool_name,
192
+ "tool_input": tool_input,
193
+ "tool_output": tool_output,
194
+ "is_error": is_error,
195
+ })
196
+
197
+ timeout_secs = hook.timeout_ms / 1000.0
198
+
199
+ process = await asyncio.create_subprocess_shell(
200
+ hook.command,
201
+ stdin=asyncio.subprocess.PIPE,
202
+ stdout=asyncio.subprocess.PIPE,
203
+ stderr=asyncio.subprocess.PIPE,
204
+ env=env,
205
+ )
206
+
207
+ try:
208
+ stdout, stderr = await asyncio.wait_for(
209
+ process.communicate(input=payload.encode()),
210
+ timeout=timeout_secs,
211
+ )
212
+ except asyncio.TimeoutError:
213
+ process.kill()
214
+ await process.wait()
215
+ return HookRunResult(failed=True, messages=["Hook timed out"])
216
+
217
+ exit_code = process.returncode
218
+ result = HookRunResult()
219
+
220
+ if stdout:
221
+ result.messages.append(stdout.decode("utf-8", errors="replace").strip())
222
+
223
+ # Exit code 0 = allow, 2 = deny, anything else = error
224
+ if exit_code == 0:
225
+ pass
226
+ elif exit_code == 2:
227
+ result.denied = True
228
+ reason = stderr.decode("utf-8", errors="replace").strip() if stderr else "Hook denied"
229
+ result.messages.append(reason)
230
+ else:
231
+ result.failed = True
232
+ if stderr:
233
+ result.messages.append(stderr.decode("utf-8", errors="replace").strip())
234
+
235
+ return result
axion/runtime/image.py ADDED
@@ -0,0 +1,212 @@
1
+ """Image utilities — clipboard capture, file loading, base64 encoding.
2
+
3
+ Supports:
4
+ - Grabbing screenshots/images from clipboard (Win32, macOS, Linux)
5
+ - Loading image files from disk (png, jpg, gif, webp)
6
+ - Converting to base64 for API submission
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import logging
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Supported image extensions
18
+ IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
19
+
20
+ MIME_TYPES = {
21
+ ".png": "image/png",
22
+ ".jpg": "image/jpeg",
23
+ ".jpeg": "image/jpeg",
24
+ ".gif": "image/gif",
25
+ ".webp": "image/webp",
26
+ ".bmp": "image/bmp",
27
+ }
28
+
29
+ # Max image size: 5MB (Anthropic limit is ~20MB but we cap lower for speed)
30
+ MAX_IMAGE_BYTES = 5 * 1024 * 1024
31
+
32
+
33
+ def is_image_path(text: str) -> bool:
34
+ """Check if text looks like an image file path."""
35
+ text = text.strip().strip('"').strip("'")
36
+ try:
37
+ p = Path(text)
38
+ return p.suffix.lower() in IMAGE_EXTENSIONS and p.exists()
39
+ except (OSError, ValueError):
40
+ return False
41
+
42
+
43
+ def load_image_file(path: str) -> tuple[str, str] | None:
44
+ """Load an image file and return (media_type, base64_data) or None."""
45
+ p = Path(path.strip().strip('"').strip("'"))
46
+ if not p.exists():
47
+ return None
48
+
49
+ suffix = p.suffix.lower()
50
+ media_type = MIME_TYPES.get(suffix)
51
+ if not media_type:
52
+ return None
53
+
54
+ try:
55
+ data = p.read_bytes()
56
+ if len(data) > MAX_IMAGE_BYTES:
57
+ logger.warning("Image too large: %d bytes (max %d)", len(data), MAX_IMAGE_BYTES)
58
+ return None
59
+ b64 = base64.b64encode(data).decode("ascii")
60
+ return media_type, b64
61
+ except (OSError, IOError) as exc:
62
+ logger.warning("Failed to read image %s: %s", path, exc)
63
+ return None
64
+
65
+
66
+ def grab_clipboard_image() -> tuple[str, str] | None:
67
+ """Grab an image from the system clipboard.
68
+
69
+ Returns (media_type, base64_data) or None if no image on clipboard.
70
+ Works on Windows, macOS, and Linux (with xclip).
71
+ """
72
+ import sys
73
+
74
+ if sys.platform == "win32":
75
+ return _clipboard_win32()
76
+ elif sys.platform == "darwin":
77
+ return _clipboard_macos()
78
+ else:
79
+ return _clipboard_linux()
80
+
81
+
82
+ def _clipboard_win32() -> tuple[str, str] | None:
83
+ """Grab clipboard image on Windows using win32clipboard or Pillow."""
84
+ try:
85
+ # Try Pillow's ImageGrab (most reliable on Windows)
86
+ import io
87
+
88
+ from PIL import ImageGrab
89
+
90
+ img = ImageGrab.grabclipboard()
91
+ if img is None:
92
+ return None
93
+
94
+ # Convert to PNG bytes
95
+ buf = io.BytesIO()
96
+ img.save(buf, format="PNG")
97
+ data = buf.getvalue()
98
+
99
+ if len(data) > MAX_IMAGE_BYTES:
100
+ return None
101
+
102
+ b64 = base64.b64encode(data).decode("ascii")
103
+ return "image/png", b64
104
+ except ImportError:
105
+ pass
106
+ except Exception as exc:
107
+ logger.debug("Pillow clipboard grab failed: %s", exc)
108
+
109
+ # Fallback: try PowerShell
110
+ try:
111
+ import subprocess
112
+ import tempfile
113
+
114
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
115
+ tmp_path = tmp.name
116
+
117
+ # PowerShell one-liner to save clipboard image
118
+ ps_cmd = (
119
+ f'Add-Type -AssemblyName System.Windows.Forms; '
120
+ f'$img = [System.Windows.Forms.Clipboard]::GetImage(); '
121
+ f'if ($img) {{ $img.Save("{tmp_path}") }} '
122
+ f'else {{ exit 1 }}'
123
+ )
124
+ result = subprocess.run(
125
+ ["powershell", "-NoProfile", "-Command", ps_cmd],
126
+ capture_output=True, timeout=5,
127
+ )
128
+
129
+ if result.returncode == 0:
130
+ p = Path(tmp_path)
131
+ if p.exists() and p.stat().st_size > 0:
132
+ data = p.read_bytes()
133
+ p.unlink(missing_ok=True)
134
+ if len(data) <= MAX_IMAGE_BYTES:
135
+ b64 = base64.b64encode(data).decode("ascii")
136
+ return "image/png", b64
137
+ p.unlink(missing_ok=True)
138
+ except Exception as exc:
139
+ logger.debug("PowerShell clipboard grab failed: %s", exc)
140
+
141
+ return None
142
+
143
+
144
+ def _clipboard_macos() -> tuple[str, str] | None:
145
+ """Grab clipboard image on macOS using pbpaste/osascript."""
146
+ try:
147
+ import subprocess
148
+ import tempfile
149
+
150
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
151
+ tmp_path = tmp.name
152
+
153
+ subprocess.run(
154
+ ["osascript", "-e",
155
+ f'set fp to POSIX file "{tmp_path}"\n'
156
+ f'try\n'
157
+ f' set img to the clipboard as «class PNGf»\n'
158
+ f' set fh to open for access fp with write permission\n'
159
+ f' write img to fh\n'
160
+ f' close access fh\n'
161
+ f'on error\n'
162
+ f' return "no image"\n'
163
+ f'end try'],
164
+ capture_output=True, timeout=5,
165
+ )
166
+
167
+ p = Path(tmp_path)
168
+ if p.exists() and p.stat().st_size > 0:
169
+ data = p.read_bytes()
170
+ p.unlink(missing_ok=True)
171
+ if len(data) <= MAX_IMAGE_BYTES:
172
+ b64 = base64.b64encode(data).decode("ascii")
173
+ return "image/png", b64
174
+ p.unlink(missing_ok=True)
175
+ except Exception as exc:
176
+ logger.debug("macOS clipboard grab failed: %s", exc)
177
+
178
+ return None
179
+
180
+
181
+ def _clipboard_linux() -> tuple[str, str] | None:
182
+ """Grab clipboard image on Linux using xclip."""
183
+ try:
184
+ import subprocess
185
+
186
+ result = subprocess.run(
187
+ ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"],
188
+ capture_output=True, timeout=5,
189
+ )
190
+
191
+ if result.returncode == 0 and result.stdout:
192
+ data = result.stdout
193
+ if len(data) <= MAX_IMAGE_BYTES:
194
+ b64 = base64.b64encode(data).decode("ascii")
195
+ return "image/png", b64
196
+ except FileNotFoundError:
197
+ logger.debug("xclip not found — install xclip for clipboard image support")
198
+ except Exception as exc:
199
+ logger.debug("Linux clipboard grab failed: %s", exc)
200
+
201
+ return None
202
+
203
+
204
+ def image_size_description(b64_data: str) -> str:
205
+ """Human-readable size of a base64-encoded image."""
206
+ raw_bytes = len(b64_data) * 3 // 4 # approximate
207
+ if raw_bytes < 1024:
208
+ return f"{raw_bytes} bytes"
209
+ elif raw_bytes < 1024 * 1024:
210
+ return f"{raw_bytes / 1024:.1f} KB"
211
+ else:
212
+ return f"{raw_bytes / (1024 * 1024):.1f} MB"