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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- 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"
|