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/bash.py ADDED
@@ -0,0 +1,388 @@
1
+ """Bash command execution with timeout, sandbox, and background support.
2
+
3
+ Maps to: rust/crates/runtime/src/bash.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import enum
10
+ import logging
11
+ import os
12
+ import sys
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ DEFAULT_TIMEOUT_MS = 120_000 # 2 minutes
20
+ MAX_TIMEOUT_MS = 600_000 # 10 minutes
21
+ MAX_OUTPUT_BYTES = 16_384 # 16 KiB (matching Rust)
22
+ TRUNCATION_NOTICE = "\n\n[output truncated — exceeded 16384 bytes]"
23
+
24
+
25
+ class FilesystemIsolationMode(enum.Enum):
26
+ NONE = "none"
27
+ WORKSPACE_ONLY = "workspace_only"
28
+ READ_ONLY = "read_only"
29
+
30
+
31
+ class SandboxStatus(enum.Enum):
32
+ DISABLED = "disabled"
33
+ ENABLED = "enabled"
34
+ UNAVAILABLE = "unavailable"
35
+
36
+
37
+ @dataclass
38
+ class BashCommandInput:
39
+ """Input for a bash command execution."""
40
+
41
+ command: str
42
+ timeout_ms: int = DEFAULT_TIMEOUT_MS
43
+ description: str = ""
44
+ run_in_background: bool = False
45
+ cwd: Path | None = None
46
+ dangerously_disable_sandbox: bool = False
47
+ namespace_restrictions: bool | None = None
48
+ isolate_network: bool | None = None
49
+ filesystem_mode: FilesystemIsolationMode | None = None
50
+ allowed_mounts: list[str] | None = None
51
+
52
+
53
+ @dataclass
54
+ class BashCommandOutput:
55
+ """Output from a bash command execution."""
56
+
57
+ stdout: str = ""
58
+ stderr: str = ""
59
+ exit_code: int | None = None
60
+ interrupted: bool = False
61
+ timed_out: bool = False
62
+ background_task_id: str | None = None
63
+ is_image: bool = False
64
+ no_output_expected: bool = False
65
+ sandbox_status: SandboxStatus | None = None
66
+ return_code_interpretation: str | None = None
67
+ raw_output_path: str | None = None
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Background task tracking
72
+ # ---------------------------------------------------------------------------
73
+
74
+ _background_tasks: dict[str, asyncio.subprocess.Process] = {}
75
+
76
+
77
+ def get_background_task(task_id: str) -> asyncio.subprocess.Process | None:
78
+ """Get a background task by ID."""
79
+ return _background_tasks.get(task_id)
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Output truncation (matching Rust)
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def truncate_output(text: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str:
87
+ """Truncate output to max_bytes, respecting UTF-8 boundaries."""
88
+ encoded = text.encode("utf-8")
89
+ if len(encoded) <= max_bytes:
90
+ return text
91
+ # Find last valid UTF-8 boundary
92
+ truncated = encoded[:max_bytes]
93
+ result = truncated.decode("utf-8", errors="ignore")
94
+ return result + TRUNCATION_NOTICE
95
+
96
+
97
+ # ---------------------------------------------------------------------------
98
+ # Sandbox support
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _build_sandbox_command(
102
+ command: str,
103
+ cwd: Path | None,
104
+ input_cfg: BashCommandInput,
105
+ ) -> list[str]:
106
+ """Build the shell command, potentially wrapped in sandbox."""
107
+ # Check for Linux sandbox tools
108
+ if sys.platform == "linux" and not input_cfg.dangerously_disable_sandbox:
109
+ bwrap = Path("/usr/bin/bwrap")
110
+ if bwrap.exists() and input_cfg.filesystem_mode:
111
+ # Build bubblewrap command
112
+ args = [
113
+ str(bwrap),
114
+ "--unshare-all",
115
+ "--die-with-parent",
116
+ ]
117
+ # Add filesystem mounts
118
+ if input_cfg.filesystem_mode == FilesystemIsolationMode.WORKSPACE_ONLY:
119
+ workspace = str(cwd) if cwd else os.getcwd()
120
+ args.extend(["--bind", workspace, workspace])
121
+ args.extend(["--ro-bind", "/usr", "/usr"])
122
+ args.extend(["--ro-bind", "/bin", "/bin"])
123
+ args.extend(["--ro-bind", "/lib", "/lib"])
124
+ if Path("/lib64").exists():
125
+ args.extend(["--ro-bind", "/lib64", "/lib64"])
126
+ args.extend(["--proc", "/proc"])
127
+ args.extend(["--dev", "/dev"])
128
+ args.extend(["--tmpfs", "/tmp"])
129
+ elif input_cfg.filesystem_mode == FilesystemIsolationMode.READ_ONLY:
130
+ args.extend(["--ro-bind", "/", "/"])
131
+
132
+ # Network isolation
133
+ if input_cfg.isolate_network:
134
+ args.extend(["--unshare-net"])
135
+
136
+ # Additional mounts
137
+ if input_cfg.allowed_mounts:
138
+ for mount in input_cfg.allowed_mounts:
139
+ args.extend(["--bind", mount, mount])
140
+
141
+ args.extend(["--", "sh", "-c", command])
142
+ return args
143
+
144
+ # Default: use available shell
145
+ if sys.platform == "win32":
146
+ # On Windows, try Git Bash first, then fall back to cmd
147
+ git_bash = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Git" / "bin" / "bash.exe"
148
+ if git_bash.exists():
149
+ return [str(git_bash), "-c", command]
150
+ # Fallback to cmd.exe
151
+ return ["cmd", "/C", command]
152
+ return ["/bin/bash", "-lc", command]
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Sandbox home directory setup
157
+ # ---------------------------------------------------------------------------
158
+
159
+ def _prepare_sandbox_dirs(cwd: Path | None) -> dict[str, str]:
160
+ """Create sandbox home/tmp directories and return env overrides."""
161
+ workspace = cwd or Path.cwd()
162
+ sandbox_home = workspace / ".sandbox-home"
163
+ sandbox_tmp = workspace / ".sandbox-tmp"
164
+ sandbox_home.mkdir(exist_ok=True)
165
+ sandbox_tmp.mkdir(exist_ok=True)
166
+ return {
167
+ "HOME": str(sandbox_home),
168
+ "TMPDIR": str(sandbox_tmp),
169
+ }
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Process helpers
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def _kill_process(process: asyncio.subprocess.Process) -> None:
177
+ """Kill a subprocess safely."""
178
+ try:
179
+ process.kill()
180
+ except (ProcessLookupError, OSError):
181
+ pass
182
+
183
+
184
+ async def _stream_process(
185
+ process: asyncio.subprocess.Process,
186
+ description: str = "",
187
+ ) -> tuple[bytes, str]:
188
+ """Read stdout fully while showing a live updating status line on stderr.
189
+
190
+ Shows a single line that keeps updating with the latest stderr output,
191
+ like Claude Code's "Running...", "Installing packages...", etc.
192
+
193
+ Returns (stdout_bytes, stderr_text).
194
+ """
195
+ assert process.stdout is not None
196
+ assert process.stderr is not None
197
+
198
+ stderr_lines: list[str] = []
199
+ _spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
200
+ _frame_idx = [0]
201
+
202
+ def _clean_status(line: str) -> str:
203
+ """Extract a clean status message from a stderr line."""
204
+ line = line.strip()
205
+ if not line:
206
+ return ""
207
+ # Skip noisy lines (progress bars, ANSI escapes, blank/repeated)
208
+ if line.startswith(("\x1b", "\033")) or set(line) <= set("-=>#. "):
209
+ return ""
210
+ # Truncate to fit one line
211
+ return line[:80]
212
+
213
+ async def _read_stderr() -> None:
214
+ """Read stderr line-by-line and update the status line."""
215
+ assert process.stderr is not None
216
+ while True:
217
+ line_bytes = await process.stderr.readline()
218
+ if not line_bytes:
219
+ break
220
+ line = line_bytes.decode("utf-8", errors="replace").rstrip("\n\r")
221
+ stderr_lines.append(line)
222
+
223
+ status = _clean_status(line)
224
+ if status:
225
+ frame = _spinner_frames[_frame_idx[0] % len(_spinner_frames)]
226
+ _frame_idx[0] += 1
227
+ # Overwrite the same line — \r returns to start, \033[K clears to end
228
+ sys.stderr.write(f"\r\033[K {frame} {status}")
229
+ sys.stderr.flush()
230
+
231
+ async def _read_stdout() -> bytes:
232
+ """Also show status updates from stdout for commands that log there."""
233
+ assert process.stdout is not None
234
+ chunks: list[bytes] = []
235
+ while True:
236
+ chunk = await process.stdout.read(4096)
237
+ if not chunk:
238
+ break
239
+ chunks.append(chunk)
240
+ # Extract last line for status
241
+ text = chunk.decode("utf-8", errors="replace")
242
+ last_line = text.rstrip().rsplit("\n", 1)[-1]
243
+ status = _clean_status(last_line)
244
+ if status:
245
+ frame = _spinner_frames[_frame_idx[0] % len(_spinner_frames)]
246
+ _frame_idx[0] += 1
247
+ sys.stderr.write(f"\r\033[K {frame} {status}")
248
+ sys.stderr.flush()
249
+ return b"".join(chunks)
250
+
251
+ # Show initial status
252
+ label = description or "Running"
253
+ sys.stderr.write(f"\r\033[K {_spinner_frames[0]} {label}...")
254
+ sys.stderr.flush()
255
+
256
+ # Run stderr reader concurrently with stdout collection
257
+ stderr_task = asyncio.create_task(_read_stderr())
258
+ stdout_bytes = await _read_stdout()
259
+ await stderr_task
260
+ await process.wait()
261
+
262
+ # Clear the status line
263
+ sys.stderr.write("\r\033[K")
264
+ sys.stderr.flush()
265
+
266
+ return stdout_bytes, "\n".join(stderr_lines)
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # Main execution
271
+ # ---------------------------------------------------------------------------
272
+
273
+ async def execute_bash(input_cfg: BashCommandInput) -> BashCommandOutput:
274
+ """Execute a bash command asynchronously.
275
+
276
+ Streams stderr to the terminal in real-time so the user can see progress
277
+ from long-running commands (builds, installs, copies). Ctrl+C during
278
+ execution kills only this command, not the whole session.
279
+
280
+ Maps to: rust/crates/runtime/src/bash.rs::execute_bash
281
+ """
282
+ timeout_secs = min(input_cfg.timeout_ms, MAX_TIMEOUT_MS) / 1000.0
283
+ cwd = str(input_cfg.cwd) if input_cfg.cwd else None
284
+
285
+ # Background execution
286
+ if input_cfg.run_in_background:
287
+ return await _execute_background(input_cfg, cwd)
288
+
289
+ # Build command
290
+ shell_cmd = _build_sandbox_command(input_cfg.command, input_cfg.cwd, input_cfg)
291
+
292
+ # Build environment
293
+ env = dict(os.environ)
294
+ if input_cfg.filesystem_mode and input_cfg.filesystem_mode != FilesystemIsolationMode.NONE:
295
+ sandbox_env = _prepare_sandbox_dirs(input_cfg.cwd)
296
+ env.update(sandbox_env)
297
+
298
+ try:
299
+ process = await asyncio.create_subprocess_exec(
300
+ *shell_cmd,
301
+ stdout=asyncio.subprocess.PIPE,
302
+ stderr=asyncio.subprocess.PIPE,
303
+ cwd=cwd,
304
+ env=env,
305
+ )
306
+
307
+ try:
308
+ # Stream stderr in real-time while collecting stdout
309
+ stdout_bytes, stderr_text = await asyncio.wait_for(
310
+ _stream_process(process, description=input_cfg.description),
311
+ timeout=timeout_secs,
312
+ )
313
+ except asyncio.CancelledError:
314
+ # Ctrl+C hit — kill just this command
315
+ _kill_process(process)
316
+ return BashCommandOutput(
317
+ stdout="",
318
+ stderr="Command cancelled by user (Ctrl+C)",
319
+ exit_code=None,
320
+ interrupted=True,
321
+ )
322
+ except asyncio.TimeoutError:
323
+ _kill_process(process)
324
+ return BashCommandOutput(
325
+ stdout="",
326
+ stderr=f"Command exceeded timeout of {input_cfg.timeout_ms}ms",
327
+ exit_code=None,
328
+ timed_out=True,
329
+ interrupted=True,
330
+ )
331
+
332
+ stdout = truncate_output(stdout_bytes.decode("utf-8", errors="replace"))
333
+ stderr = truncate_output(stderr_text)
334
+
335
+ exit_code = process.returncode
336
+ return_code_interp = None
337
+ if exit_code is not None and exit_code != 0:
338
+ return_code_interp = f"exit_code:{exit_code}"
339
+
340
+ return BashCommandOutput(
341
+ stdout=stdout,
342
+ stderr=stderr,
343
+ exit_code=exit_code,
344
+ no_output_expected=(not stdout.strip() and not stderr.strip()),
345
+ return_code_interpretation=return_code_interp,
346
+ )
347
+
348
+ except FileNotFoundError:
349
+ return BashCommandOutput(
350
+ stdout="",
351
+ stderr="bash: command not found. Is bash installed?",
352
+ exit_code=127,
353
+ )
354
+ except Exception as exc:
355
+ logger.error("Failed to execute bash command: %s", exc)
356
+ return BashCommandOutput(
357
+ stdout="",
358
+ stderr=f"Execution error: {exc}",
359
+ exit_code=1,
360
+ )
361
+
362
+
363
+ async def _execute_background(
364
+ input_cfg: BashCommandInput, cwd: str | None
365
+ ) -> BashCommandOutput:
366
+ """Execute a command in the background, returning immediately."""
367
+ shell_cmd = _build_sandbox_command(input_cfg.command, input_cfg.cwd, input_cfg)
368
+
369
+ try:
370
+ process = await asyncio.create_subprocess_exec(
371
+ *shell_cmd,
372
+ stdout=asyncio.subprocess.DEVNULL,
373
+ stderr=asyncio.subprocess.DEVNULL,
374
+ cwd=cwd,
375
+ )
376
+
377
+ task_id = f"bg-{uuid.uuid4().hex[:8]}"
378
+ _background_tasks[task_id] = process
379
+
380
+ return BashCommandOutput(
381
+ background_task_id=task_id,
382
+ stdout=f"Background task started: {task_id} (PID: {process.pid})",
383
+ )
384
+ except Exception as exc:
385
+ return BashCommandOutput(
386
+ stderr=f"Failed to start background task: {exc}",
387
+ exit_code=1,
388
+ )
@@ -0,0 +1,39 @@
1
+ """Bootstrap phases for CLI startup.
2
+
3
+ Maps to: rust/crates/runtime/src/bootstrap.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ class BootstrapPhase(enum.Enum):
13
+ VERSION_CHECK = "version_check"
14
+ PROFILER = "profiler"
15
+ SYSTEM_PROMPT = "system_prompt"
16
+ DAEMON = "daemon"
17
+ AUTH = "auth"
18
+ CONFIG = "config"
19
+ PLUGINS = "plugins"
20
+ MCP = "mcp"
21
+ READY = "ready"
22
+
23
+
24
+ @dataclass
25
+ class BootstrapPlan:
26
+ """Ordered list of bootstrap phases to execute."""
27
+
28
+ phases: list[BootstrapPhase] = field(default_factory=lambda: [
29
+ BootstrapPhase.VERSION_CHECK,
30
+ BootstrapPhase.CONFIG,
31
+ BootstrapPhase.AUTH,
32
+ BootstrapPhase.PLUGINS,
33
+ BootstrapPhase.MCP,
34
+ BootstrapPhase.SYSTEM_PROMPT,
35
+ BootstrapPhase.READY,
36
+ ])
37
+
38
+ def includes(self, phase: BootstrapPhase) -> bool:
39
+ return phase in self.phases