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/cli/main.py
ADDED
|
@@ -0,0 +1,2953 @@
|
|
|
1
|
+
"""CLI entry point for Axion Code.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/rusty-claude-cli/src/main.rs
|
|
4
|
+
|
|
5
|
+
Comprehensive CLI with:
|
|
6
|
+
- All subcommands (status, sandbox, agents, mcp, skills, plugins, system-prompt,
|
|
7
|
+
login, logout, doctor, init, version, resume, export)
|
|
8
|
+
- Full interactive REPL with 40+ slash commands
|
|
9
|
+
- JSON output mode for scripting
|
|
10
|
+
- Session persistence and resume
|
|
11
|
+
- Tool display with box-drawing characters
|
|
12
|
+
- Permission prompting
|
|
13
|
+
- OAuth login/logout
|
|
14
|
+
- Configuration display
|
|
15
|
+
- Transcript export
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import click
|
|
32
|
+
from rich.console import Console
|
|
33
|
+
from rich.markdown import Markdown
|
|
34
|
+
|
|
35
|
+
from axion import __version__
|
|
36
|
+
from axion.api.client import (
|
|
37
|
+
ProviderClient,
|
|
38
|
+
resolve_model_alias,
|
|
39
|
+
)
|
|
40
|
+
from axion.cli.render import CLAW_THEME, MarkdownStreamState, TerminalRenderer
|
|
41
|
+
from axion.cli.tui import (
|
|
42
|
+
render_permission_panel,
|
|
43
|
+
render_tool_call_inline,
|
|
44
|
+
render_tool_result_inline,
|
|
45
|
+
render_welcome_screen,
|
|
46
|
+
)
|
|
47
|
+
from axion.commands.handlers.agents import handle_agents_command
|
|
48
|
+
from axion.commands.handlers.builtin_commands import (
|
|
49
|
+
handle_commit_command,
|
|
50
|
+
handle_init_project_command,
|
|
51
|
+
handle_review_command,
|
|
52
|
+
handle_test_command,
|
|
53
|
+
handle_undo_command,
|
|
54
|
+
)
|
|
55
|
+
from axion.commands.handlers.mcp import handle_mcp_command
|
|
56
|
+
from axion.commands.handlers.plugins import handle_plugins_command
|
|
57
|
+
from axion.commands.handlers.skills import handle_skills_command
|
|
58
|
+
from axion.commands.parsing import (
|
|
59
|
+
CommandParseError,
|
|
60
|
+
ParsedCommand,
|
|
61
|
+
parse_slash_command,
|
|
62
|
+
render_help,
|
|
63
|
+
)
|
|
64
|
+
from axion.plugins.manager import PluginManager
|
|
65
|
+
from axion.runtime.compact import (
|
|
66
|
+
CompactionConfig,
|
|
67
|
+
compact_session,
|
|
68
|
+
estimate_session_tokens,
|
|
69
|
+
)
|
|
70
|
+
from axion.runtime.config import ConfigLoader, RuntimeConfig
|
|
71
|
+
from axion.runtime.conversation import ConversationRuntime, TurnSummary
|
|
72
|
+
from axion.runtime.oauth import (
|
|
73
|
+
clear_oauth_credentials,
|
|
74
|
+
load_oauth_credentials,
|
|
75
|
+
)
|
|
76
|
+
from axion.runtime.permissions import (
|
|
77
|
+
PermissionMode,
|
|
78
|
+
PermissionPolicy,
|
|
79
|
+
PermissionPromptDecision,
|
|
80
|
+
PermissionRequest,
|
|
81
|
+
)
|
|
82
|
+
from axion.runtime.prompt import SystemPromptBuilder
|
|
83
|
+
from axion.runtime.sandbox import detect_sandbox
|
|
84
|
+
from axion.runtime.session import (
|
|
85
|
+
Session,
|
|
86
|
+
TextBlock,
|
|
87
|
+
ToolResultBlock,
|
|
88
|
+
ToolUseBlock,
|
|
89
|
+
)
|
|
90
|
+
from axion.runtime.usage import UsageTracker, format_usd
|
|
91
|
+
from axion.tools.registry import BuiltinToolExecutor, get_tool_registry
|
|
92
|
+
|
|
93
|
+
logger = logging.getLogger(__name__)
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Constants
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
100
|
+
DEFAULT_OAUTH_CALLBACK_PORT = 4545
|
|
101
|
+
SESSION_DIR = ".axion/sessions"
|
|
102
|
+
HISTORY_FILE = ".axion/repl_history"
|
|
103
|
+
MAX_SESSION_LIST = 20
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _maybe_warn_subscription_unused(model: str) -> None:
|
|
107
|
+
"""Warn when a saved subscription doesn't apply to the active model.
|
|
108
|
+
|
|
109
|
+
Common confusion: user runs `axion login --subscription --provider openai`
|
|
110
|
+
then runs axion on gpt-4o and wonders why it's still using API billing.
|
|
111
|
+
The OpenAI ChatGPT subscription only authorizes Codex (Responses API).
|
|
112
|
+
"""
|
|
113
|
+
m = (model or "").lower()
|
|
114
|
+
try:
|
|
115
|
+
# ChatGPT subscription saved but model isn't codex
|
|
116
|
+
from axion.runtime.openai_subscription import has_openai_subscription_credentials
|
|
117
|
+
if has_openai_subscription_credentials() and "codex" not in m and m.startswith(("gpt-", "o1", "o3", "o4")):
|
|
118
|
+
console.print(
|
|
119
|
+
"[dim yellow]Note:[/dim yellow] [dim]you have a ChatGPT subscription saved, but it only "
|
|
120
|
+
f"works with codex models. [bold]{model}[/bold] uses your OpenAI API key. "
|
|
121
|
+
"Run [cyan]/model codex[/cyan] to use the subscription.[/dim]"
|
|
122
|
+
)
|
|
123
|
+
console.print()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _detect_auth_mode_label(model: str) -> str:
|
|
129
|
+
"""Return a UI badge label describing how the current model is authenticated.
|
|
130
|
+
|
|
131
|
+
Returns one of:
|
|
132
|
+
"subscription" — Claude Pro/Max OR ChatGPT Plus/Pro/Business
|
|
133
|
+
"api" — pay-per-token API key
|
|
134
|
+
"local" — Ollama / local model (free, runs on user's machine)
|
|
135
|
+
"" — unknown / no credentials
|
|
136
|
+
"""
|
|
137
|
+
forced = os.environ.get("AXION_AUTH_MODE", "").lower()
|
|
138
|
+
m = (model or "").lower()
|
|
139
|
+
|
|
140
|
+
# Claude → Anthropic API or Pro/Max subscription
|
|
141
|
+
if m.startswith("claude"):
|
|
142
|
+
try:
|
|
143
|
+
from axion.runtime.claude_subscription import has_subscription_credentials
|
|
144
|
+
if forced != "api" and has_subscription_credentials():
|
|
145
|
+
return "subscription"
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
return "api"
|
|
149
|
+
|
|
150
|
+
# Codex → OpenAI API or ChatGPT subscription
|
|
151
|
+
if "codex" in m:
|
|
152
|
+
try:
|
|
153
|
+
from axion.runtime.openai_subscription import has_openai_subscription_credentials
|
|
154
|
+
if forced != "api" and has_openai_subscription_credentials():
|
|
155
|
+
return "subscription"
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
return "api"
|
|
159
|
+
|
|
160
|
+
# Ollama / local models — always free, no auth
|
|
161
|
+
if any(m.startswith(p) for p in ("llama", "mistral", "qwen", "deepseek", "phi", "gemma", "codellama")):
|
|
162
|
+
return "local"
|
|
163
|
+
|
|
164
|
+
# Other OpenAI / xAI models — always API key
|
|
165
|
+
if m.startswith(("gpt-", "o1", "o3", "o4", "grok-")):
|
|
166
|
+
return "api"
|
|
167
|
+
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _auto_detect_model() -> str:
|
|
172
|
+
"""Auto-detect which model to use based on available credentials.
|
|
173
|
+
|
|
174
|
+
Checks (in order): Claude subscription OAuth > Anthropic API > OpenAI > xAI > Ollama.
|
|
175
|
+
Returns the default model for the first provider found.
|
|
176
|
+
"""
|
|
177
|
+
from pathlib import Path as _P
|
|
178
|
+
|
|
179
|
+
key_dir = _P.home() / ".axion" / "credentials"
|
|
180
|
+
|
|
181
|
+
# 0. Claude Pro/Max subscription (preferred)
|
|
182
|
+
if (key_dir / "anthropic-oauth.json").exists():
|
|
183
|
+
return "claude-sonnet-4-6"
|
|
184
|
+
|
|
185
|
+
# 1. Anthropic API key
|
|
186
|
+
if os.environ.get("ANTHROPIC_API_KEY") or (key_dir / "anthropic.key").exists():
|
|
187
|
+
return "claude-sonnet-4-6"
|
|
188
|
+
|
|
189
|
+
# 2. OpenAI
|
|
190
|
+
if os.environ.get("OPENAI_API_KEY") or (key_dir / "openai.key").exists():
|
|
191
|
+
return "gpt-4o"
|
|
192
|
+
|
|
193
|
+
# 3. xAI
|
|
194
|
+
if os.environ.get("XAI_API_KEY") or (key_dir / "xai.key").exists():
|
|
195
|
+
return "grok-2"
|
|
196
|
+
|
|
197
|
+
# 4. Ollama (check if running)
|
|
198
|
+
try:
|
|
199
|
+
import httpx
|
|
200
|
+
resp = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
|
|
201
|
+
if resp.status_code == 200:
|
|
202
|
+
return "llama3.1"
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
# Default to Anthropic (will show friendly error if no key)
|
|
207
|
+
return DEFAULT_MODEL
|
|
208
|
+
|
|
209
|
+
console = Console(theme=CLAW_THEME)
|
|
210
|
+
renderer = TerminalRenderer(console=console)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# Helpers
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def _session_dir(cwd: Path | None = None) -> Path:
|
|
218
|
+
"""Return the session directory, creating it if needed."""
|
|
219
|
+
base = cwd or Path.cwd()
|
|
220
|
+
d = base / SESSION_DIR
|
|
221
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
return d
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _session_path_for_id(session_id: str, cwd: Path | None = None) -> Path:
|
|
226
|
+
"""Return the JSONL file path for a given session ID."""
|
|
227
|
+
return _session_dir(cwd) / f"{session_id}.jsonl"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _list_sessions(cwd: Path | None = None, limit: int = MAX_SESSION_LIST) -> list[Path]:
|
|
231
|
+
"""List session files sorted by modification time (newest first)."""
|
|
232
|
+
d = _session_dir(cwd)
|
|
233
|
+
files = sorted(d.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
234
|
+
return files[:limit]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# _inject_file_context removed — using _inject_file_context instead
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _find_latest_session(cwd: Path | None = None) -> Path | None:
|
|
244
|
+
"""Find the most recently modified session file."""
|
|
245
|
+
sessions = _list_sessions(cwd, limit=1)
|
|
246
|
+
return sessions[0] if sessions else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _resolve_session(identifier: str, cwd: Path | None = None) -> Path | None:
|
|
250
|
+
"""Resolve a session from an ID, file path, or 'latest'."""
|
|
251
|
+
if identifier == "latest":
|
|
252
|
+
return _find_latest_session(cwd)
|
|
253
|
+
|
|
254
|
+
# Try as a file path first
|
|
255
|
+
as_path = Path(identifier)
|
|
256
|
+
if as_path.exists() and as_path.suffix == ".jsonl":
|
|
257
|
+
return as_path
|
|
258
|
+
|
|
259
|
+
# Try as a session ID
|
|
260
|
+
candidate = _session_path_for_id(identifier, cwd)
|
|
261
|
+
if candidate.exists():
|
|
262
|
+
return candidate
|
|
263
|
+
|
|
264
|
+
# Try partial ID match
|
|
265
|
+
d = _session_dir(cwd)
|
|
266
|
+
for f in d.glob("*.jsonl"):
|
|
267
|
+
if f.stem.startswith(identifier):
|
|
268
|
+
return f
|
|
269
|
+
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _git_branch() -> str | None:
|
|
274
|
+
"""Get the current git branch name."""
|
|
275
|
+
try:
|
|
276
|
+
result = subprocess.run(
|
|
277
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
278
|
+
capture_output=True, text=True, timeout=5,
|
|
279
|
+
encoding="utf-8", errors="replace",
|
|
280
|
+
)
|
|
281
|
+
if result.returncode == 0:
|
|
282
|
+
return result.stdout.strip()
|
|
283
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
284
|
+
pass
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _git_status_short() -> str | None:
|
|
289
|
+
"""Get short git status."""
|
|
290
|
+
try:
|
|
291
|
+
result = subprocess.run(
|
|
292
|
+
["git", "status", "--short"],
|
|
293
|
+
capture_output=True, text=True, timeout=5,
|
|
294
|
+
encoding="utf-8", errors="replace",
|
|
295
|
+
)
|
|
296
|
+
if result.returncode == 0:
|
|
297
|
+
return result.stdout.strip() or "(clean)"
|
|
298
|
+
except (subprocess.SubprocessError, FileNotFoundError, OSError):
|
|
299
|
+
pass
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class CliPermissionPrompter:
|
|
304
|
+
"""Interactive permission prompter for the CLI REPL.
|
|
305
|
+
|
|
306
|
+
Shows tool details and asks [y/N/a] where:
|
|
307
|
+
y = allow this once
|
|
308
|
+
a = allow always (remember for this tool)
|
|
309
|
+
N = deny (default)
|
|
310
|
+
|
|
311
|
+
Implements the PermissionPrompter protocol.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
def __init__(self) -> None:
|
|
315
|
+
self._stop_spinner_fn: Any = None # Set by REPL to stop spinner
|
|
316
|
+
|
|
317
|
+
async def decide(self, request: PermissionRequest) -> PermissionPromptDecision:
|
|
318
|
+
"""Show an interactive prompt and wait for user decision."""
|
|
319
|
+
# Stop the spinner before showing the prompt
|
|
320
|
+
if self._stop_spinner_fn:
|
|
321
|
+
self._stop_spinner_fn()
|
|
322
|
+
|
|
323
|
+
render_permission_panel(
|
|
324
|
+
console,
|
|
325
|
+
tool_name=request.tool_name,
|
|
326
|
+
mode=request.current_mode.value,
|
|
327
|
+
required=request.required_mode.value,
|
|
328
|
+
reason=request.reason,
|
|
329
|
+
input_preview=request.input_json,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
answer = console.input("[yellow]Allow? [y/N/a(lways)]: [/yellow]").strip().lower()
|
|
334
|
+
except (EOFError, KeyboardInterrupt):
|
|
335
|
+
console.print("[dim]Denied.[/dim]")
|
|
336
|
+
return PermissionPromptDecision.DENY
|
|
337
|
+
|
|
338
|
+
if answer in ("y", "yes"):
|
|
339
|
+
console.print("[green]Allowed (once).[/green]")
|
|
340
|
+
return PermissionPromptDecision.ALLOW
|
|
341
|
+
if answer in ("a", "always"):
|
|
342
|
+
console.print("[green]Allowed (always for this tool).[/green]")
|
|
343
|
+
return PermissionPromptDecision.ALLOW
|
|
344
|
+
|
|
345
|
+
console.print("[dim]Denied.[/dim]")
|
|
346
|
+
return PermissionPromptDecision.DENY
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _render_tool_use(tool_name: str, tool_input: str) -> None:
|
|
350
|
+
"""Display a tool invocation with box-drawing characters."""
|
|
351
|
+
try:
|
|
352
|
+
parsed = json.loads(tool_input) if tool_input else {}
|
|
353
|
+
except json.JSONDecodeError:
|
|
354
|
+
parsed = {}
|
|
355
|
+
|
|
356
|
+
# Header line
|
|
357
|
+
console.print(f"[bold yellow]\u256d\u2500 {tool_name}[/bold yellow]")
|
|
358
|
+
|
|
359
|
+
# Show key parameters
|
|
360
|
+
if isinstance(parsed, dict):
|
|
361
|
+
for key, value in list(parsed.items())[:5]:
|
|
362
|
+
val_str = str(value)
|
|
363
|
+
if len(val_str) > 200:
|
|
364
|
+
val_str = val_str[:200] + "..."
|
|
365
|
+
console.print(f"[dim]\u2502 {key}: {val_str}[/dim]")
|
|
366
|
+
|
|
367
|
+
console.print("[dim]\u2570\u2500[/dim]")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _render_tool_result(tool_name: str, output: str, is_error: bool) -> None:
|
|
371
|
+
"""Display a tool result with success/failure indicator."""
|
|
372
|
+
if is_error:
|
|
373
|
+
console.print(f"[red]\u2717 {tool_name}: {output[:500]}[/red]")
|
|
374
|
+
else:
|
|
375
|
+
truncated = output[:500] + "..." if len(output) > 500 else output
|
|
376
|
+
console.print(f"[green]\u2713 {tool_name}[/green]")
|
|
377
|
+
if truncated.strip():
|
|
378
|
+
for line in truncated.splitlines()[:10]:
|
|
379
|
+
console.print(f" [dim]{line}[/dim]")
|
|
380
|
+
if len(output.splitlines()) > 10:
|
|
381
|
+
console.print(f" [dim]... ({len(output.splitlines())} lines total)[/dim]")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _build_json_output(summary: TurnSummary, model: str) -> dict[str, Any]:
|
|
385
|
+
"""Build structured JSON output from a turn summary."""
|
|
386
|
+
tool_uses_out: list[dict[str, Any]] = []
|
|
387
|
+
tool_results_out: list[dict[str, Any]] = []
|
|
388
|
+
|
|
389
|
+
for msg in summary.assistant_messages:
|
|
390
|
+
for block in msg.blocks:
|
|
391
|
+
if isinstance(block, ToolUseBlock):
|
|
392
|
+
tool_uses_out.append({
|
|
393
|
+
"id": block.id,
|
|
394
|
+
"name": block.name,
|
|
395
|
+
"input": block.input,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
for msg in summary.tool_results:
|
|
399
|
+
for block in msg.blocks:
|
|
400
|
+
if isinstance(block, ToolResultBlock):
|
|
401
|
+
tool_results_out.append({
|
|
402
|
+
"tool_use_id": block.tool_use_id,
|
|
403
|
+
"tool_name": block.tool_name,
|
|
404
|
+
"output": block.output,
|
|
405
|
+
"is_error": block.is_error,
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
cost = summary.usage.estimate_cost_usd()
|
|
409
|
+
return {
|
|
410
|
+
"message": summary.text_output,
|
|
411
|
+
"model": model,
|
|
412
|
+
"iterations": summary.iterations,
|
|
413
|
+
"tool_uses": tool_uses_out,
|
|
414
|
+
"tool_results": tool_results_out,
|
|
415
|
+
"usage": {
|
|
416
|
+
"input_tokens": summary.usage.input_tokens,
|
|
417
|
+
"output_tokens": summary.usage.output_tokens,
|
|
418
|
+
"cache_creation_input_tokens": summary.usage.cache_creation_input_tokens,
|
|
419
|
+
"cache_read_input_tokens": summary.usage.cache_read_input_tokens,
|
|
420
|
+
"total_tokens": summary.usage.total_tokens(),
|
|
421
|
+
},
|
|
422
|
+
"estimated_cost": cost.total_cost_usd(),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _load_config() -> RuntimeConfig:
|
|
427
|
+
"""Load merged configuration from all sources."""
|
|
428
|
+
loader = ConfigLoader(project_dir=Path.cwd())
|
|
429
|
+
return loader.load()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _create_plugin_manager() -> PluginManager:
|
|
433
|
+
"""Create and initialize the plugin manager."""
|
|
434
|
+
manager = PluginManager()
|
|
435
|
+
manager.discover_plugins()
|
|
436
|
+
return manager
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _export_transcript(session: Session, output_path: Path) -> None:
|
|
440
|
+
"""Export a session transcript to a clean, readable markdown file."""
|
|
441
|
+
lines: list[str] = []
|
|
442
|
+
|
|
443
|
+
# Header
|
|
444
|
+
created = datetime.fromtimestamp(session.created_at_ms / 1000)
|
|
445
|
+
lines.append("# Axion Code — Session Transcript")
|
|
446
|
+
lines.append("")
|
|
447
|
+
lines.append(f"> **Session**: `{session.session_id}`")
|
|
448
|
+
lines.append(f"> **Date**: {created.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
449
|
+
lines.append(f"> **Messages**: {session.message_count()}")
|
|
450
|
+
if session.fork:
|
|
451
|
+
lines.append(f"> **Forked from**: `{session.fork.parent_session_id}`")
|
|
452
|
+
if session.compaction:
|
|
453
|
+
lines.append(f"> **Compactions**: {session.compaction.count}")
|
|
454
|
+
lines.append("")
|
|
455
|
+
lines.append("---")
|
|
456
|
+
lines.append("")
|
|
457
|
+
|
|
458
|
+
turn_number = 0
|
|
459
|
+
for msg in session.messages:
|
|
460
|
+
role = msg.role.value
|
|
461
|
+
|
|
462
|
+
if role == "user":
|
|
463
|
+
turn_number += 1
|
|
464
|
+
lines.append(f"## Turn {turn_number}")
|
|
465
|
+
lines.append("")
|
|
466
|
+
lines.append("### You")
|
|
467
|
+
lines.append("")
|
|
468
|
+
elif role == "assistant":
|
|
469
|
+
lines.append("### Axion")
|
|
470
|
+
lines.append("")
|
|
471
|
+
elif role == "system":
|
|
472
|
+
lines.append("### System")
|
|
473
|
+
lines.append("")
|
|
474
|
+
|
|
475
|
+
for block in msg.blocks:
|
|
476
|
+
if isinstance(block, TextBlock):
|
|
477
|
+
lines.append(block.text)
|
|
478
|
+
lines.append("")
|
|
479
|
+
elif isinstance(block, ToolUseBlock):
|
|
480
|
+
lines.append("<details>")
|
|
481
|
+
lines.append(f"<summary>🔧 <strong>{block.name}</strong></summary>")
|
|
482
|
+
lines.append("")
|
|
483
|
+
lines.append("```json")
|
|
484
|
+
# Pretty-print the input JSON
|
|
485
|
+
try:
|
|
486
|
+
import json as _json
|
|
487
|
+
parsed = _json.loads(block.input) if block.input else {}
|
|
488
|
+
lines.append(_json.dumps(parsed, indent=2))
|
|
489
|
+
except Exception:
|
|
490
|
+
lines.append(block.input)
|
|
491
|
+
lines.append("```")
|
|
492
|
+
lines.append("</details>")
|
|
493
|
+
lines.append("")
|
|
494
|
+
elif isinstance(block, ToolResultBlock):
|
|
495
|
+
icon = "❌" if block.is_error else "✅"
|
|
496
|
+
status = "Error" if block.is_error else "Result"
|
|
497
|
+
lines.append("<details>")
|
|
498
|
+
lines.append(f"<summary>{icon} <strong>{block.tool_name}</strong> — {status}</summary>")
|
|
499
|
+
lines.append("")
|
|
500
|
+
lines.append("```")
|
|
501
|
+
output = block.output
|
|
502
|
+
if len(output) > 3000:
|
|
503
|
+
output = output[:3000] + "\n... (truncated)"
|
|
504
|
+
lines.append(output)
|
|
505
|
+
lines.append("```")
|
|
506
|
+
lines.append("</details>")
|
|
507
|
+
lines.append("")
|
|
508
|
+
|
|
509
|
+
if role == "assistant" and msg.usage:
|
|
510
|
+
cost = msg.usage.estimate_cost_usd()
|
|
511
|
+
lines.append(
|
|
512
|
+
f"*Tokens: {msg.usage.total_tokens():,} | "
|
|
513
|
+
f"Cost: ${cost.total_cost_usd():.4f}*"
|
|
514
|
+
)
|
|
515
|
+
lines.append("")
|
|
516
|
+
|
|
517
|
+
lines.append("---")
|
|
518
|
+
lines.append("")
|
|
519
|
+
|
|
520
|
+
# Footer
|
|
521
|
+
lines.append(f"*Exported by Axion Code on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*")
|
|
522
|
+
lines.append("")
|
|
523
|
+
|
|
524
|
+
output_path.write_text("\n".join(lines), encoding="utf-8")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# ---------------------------------------------------------------------------
|
|
528
|
+
# Runtime builder
|
|
529
|
+
# ---------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
def _build_runtime(
|
|
532
|
+
model: str,
|
|
533
|
+
permission_mode: str,
|
|
534
|
+
session: Session,
|
|
535
|
+
config: RuntimeConfig | None = None,
|
|
536
|
+
on_text_delta: Any = None,
|
|
537
|
+
on_tool_use: Any = None,
|
|
538
|
+
on_tool_result: Any = None,
|
|
539
|
+
) -> tuple[ConversationRuntime, ProviderClient]:
|
|
540
|
+
"""Build a ConversationRuntime with all components wired up."""
|
|
541
|
+
cfg = config or _load_config()
|
|
542
|
+
|
|
543
|
+
# Resolve model: explicit flag > auto-detect from saved keys > config
|
|
544
|
+
# Auto-detect takes priority over config because config might reference
|
|
545
|
+
# a provider without a saved key (e.g. Claude settings with no Anthropic key)
|
|
546
|
+
if model:
|
|
547
|
+
effective_model = resolve_model_alias(model)
|
|
548
|
+
else:
|
|
549
|
+
detected = _auto_detect_model()
|
|
550
|
+
if detected != DEFAULT_MODEL:
|
|
551
|
+
# Auto-detect found a saved key — use that provider
|
|
552
|
+
effective_model = resolve_model_alias(detected)
|
|
553
|
+
elif cfg.feature_config.model:
|
|
554
|
+
effective_model = resolve_model_alias(cfg.feature_config.model)
|
|
555
|
+
else:
|
|
556
|
+
effective_model = resolve_model_alias(DEFAULT_MODEL)
|
|
557
|
+
|
|
558
|
+
# Build provider
|
|
559
|
+
provider = ProviderClient.from_model(effective_model)
|
|
560
|
+
|
|
561
|
+
# Build system prompt (render to string, not list)
|
|
562
|
+
prompt_builder = SystemPromptBuilder.for_cwd()
|
|
563
|
+
system_prompt = prompt_builder.render()
|
|
564
|
+
|
|
565
|
+
# Build permission policy
|
|
566
|
+
effective_perm = permission_mode
|
|
567
|
+
if effective_perm == "allow" and cfg.feature_config.permission_mode:
|
|
568
|
+
effective_perm = cfg.feature_config.permission_mode
|
|
569
|
+
|
|
570
|
+
# Map Claude Code-style permission modes to Axion's PermissionMode enum
|
|
571
|
+
# (allows shared settings.json files between Claude Code and Axion)
|
|
572
|
+
_CLAUDE_CODE_MODE_MAP = {
|
|
573
|
+
"default": "prompt",
|
|
574
|
+
"acceptEdits": "workspace-write",
|
|
575
|
+
"plan": "read-only",
|
|
576
|
+
"bypassPermissions": "danger-full-access",
|
|
577
|
+
"dontAsk": "allow",
|
|
578
|
+
}
|
|
579
|
+
if effective_perm in _CLAUDE_CODE_MODE_MAP:
|
|
580
|
+
effective_perm = _CLAUDE_CODE_MODE_MAP[effective_perm]
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
mode = PermissionMode(effective_perm) if effective_perm != "allow" else PermissionMode.ALLOW
|
|
584
|
+
except ValueError:
|
|
585
|
+
# Unknown permission mode in config — fall back to allow with a warning
|
|
586
|
+
console.print(
|
|
587
|
+
f"[yellow]Warning: unknown permission mode '{effective_perm}' in config — defaulting to 'allow'[/yellow]"
|
|
588
|
+
)
|
|
589
|
+
mode = PermissionMode.ALLOW
|
|
590
|
+
policy = PermissionPolicy(mode=mode)
|
|
591
|
+
|
|
592
|
+
# Build tool executor
|
|
593
|
+
tool_executor = BuiltinToolExecutor(cwd=str(Path.cwd()))
|
|
594
|
+
|
|
595
|
+
runtime = ConversationRuntime(
|
|
596
|
+
session=session,
|
|
597
|
+
provider=provider,
|
|
598
|
+
tool_executor=tool_executor,
|
|
599
|
+
permission_policy=policy,
|
|
600
|
+
permission_prompter=CliPermissionPrompter(),
|
|
601
|
+
system_prompt=system_prompt,
|
|
602
|
+
model=effective_model,
|
|
603
|
+
on_text_delta=on_text_delta,
|
|
604
|
+
on_tool_use=on_tool_use,
|
|
605
|
+
on_tool_result=on_tool_result,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
return runtime, provider
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
# One-shot execution
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
|
|
615
|
+
async def run_one_shot(
|
|
616
|
+
prompt: str,
|
|
617
|
+
model: str,
|
|
618
|
+
permission_mode: str,
|
|
619
|
+
output_format: str = "text",
|
|
620
|
+
) -> int:
|
|
621
|
+
"""Execute a single prompt and print the streaming response."""
|
|
622
|
+
config = _load_config()
|
|
623
|
+
session = Session()
|
|
624
|
+
|
|
625
|
+
text_buffer: list[str] = []
|
|
626
|
+
md_stream = MarkdownStreamState()
|
|
627
|
+
|
|
628
|
+
def on_text_delta(text: str) -> None:
|
|
629
|
+
text_buffer.append(text)
|
|
630
|
+
if output_format == "json":
|
|
631
|
+
return
|
|
632
|
+
# Use markdown streaming: buffer until safe boundary, then render
|
|
633
|
+
rendered = md_stream.push(renderer, text)
|
|
634
|
+
if rendered:
|
|
635
|
+
console.print(Markdown(rendered), end="")
|
|
636
|
+
|
|
637
|
+
runtime, provider = _build_runtime(
|
|
638
|
+
model=model,
|
|
639
|
+
permission_mode=permission_mode,
|
|
640
|
+
session=session,
|
|
641
|
+
config=config,
|
|
642
|
+
on_text_delta=on_text_delta,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
try:
|
|
646
|
+
summary = await runtime.run_turn(prompt)
|
|
647
|
+
|
|
648
|
+
# Flush any remaining markdown
|
|
649
|
+
if output_format != "json":
|
|
650
|
+
remaining = md_stream.flush(renderer)
|
|
651
|
+
if remaining:
|
|
652
|
+
console.print(Markdown(remaining))
|
|
653
|
+
|
|
654
|
+
if output_format == "json":
|
|
655
|
+
json_out = _build_json_output(summary, runtime.model)
|
|
656
|
+
click.echo(json.dumps(json_out, indent=2))
|
|
657
|
+
else:
|
|
658
|
+
console.print() # Newline after streaming
|
|
659
|
+
|
|
660
|
+
# Display tool activity
|
|
661
|
+
for msg in summary.assistant_messages:
|
|
662
|
+
for block in msg.blocks:
|
|
663
|
+
if isinstance(block, ToolUseBlock):
|
|
664
|
+
_render_tool_use(block.name, block.input)
|
|
665
|
+
for msg in summary.tool_results:
|
|
666
|
+
for block in msg.blocks:
|
|
667
|
+
if isinstance(block, ToolResultBlock):
|
|
668
|
+
_render_tool_result(block.tool_name, block.output, block.is_error)
|
|
669
|
+
|
|
670
|
+
# Print usage (model-specific pricing)
|
|
671
|
+
if summary.usage.total_tokens() > 0:
|
|
672
|
+
from axion.runtime.usage import pricing_for_model
|
|
673
|
+
mp = pricing_for_model(runtime.model)
|
|
674
|
+
cost = summary.usage.estimate_cost_usd_with_pricing(mp) if mp else summary.usage.estimate_cost_usd()
|
|
675
|
+
console.print(
|
|
676
|
+
f"\n[dim]Tokens: {summary.usage.total_tokens()} | "
|
|
677
|
+
f"Cost: {format_usd(cost.total_cost_usd())} | "
|
|
678
|
+
f"Turns: {summary.iterations}[/dim]"
|
|
679
|
+
)
|
|
680
|
+
except KeyboardInterrupt:
|
|
681
|
+
console.print("\n[yellow]Interrupted[/yellow]")
|
|
682
|
+
return 130
|
|
683
|
+
except Exception as exc:
|
|
684
|
+
if output_format == "json":
|
|
685
|
+
click.echo(json.dumps({"error": str(exc)}, indent=2))
|
|
686
|
+
else:
|
|
687
|
+
console.print(f"\n[red]Error: {exc}[/red]")
|
|
688
|
+
return 1
|
|
689
|
+
finally:
|
|
690
|
+
await provider.close()
|
|
691
|
+
|
|
692
|
+
return 0
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
# ---------------------------------------------------------------------------
|
|
696
|
+
# REPL slash command dispatcher
|
|
697
|
+
# ---------------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
async def _handle_slash_command(
|
|
700
|
+
user_input: str,
|
|
701
|
+
runtime: ConversationRuntime,
|
|
702
|
+
session: Session,
|
|
703
|
+
plugin_manager: PluginManager,
|
|
704
|
+
config: RuntimeConfig,
|
|
705
|
+
) -> str | None:
|
|
706
|
+
"""Handle a slash command. Returns None to signal exit, or a status message."""
|
|
707
|
+
result = parse_slash_command(user_input)
|
|
708
|
+
|
|
709
|
+
if isinstance(result, CommandParseError):
|
|
710
|
+
msg = result.message
|
|
711
|
+
if result.suggestions:
|
|
712
|
+
msg += f"\n Did you mean: {', '.join(result.suggestions)}?"
|
|
713
|
+
return msg
|
|
714
|
+
|
|
715
|
+
assert isinstance(result, ParsedCommand)
|
|
716
|
+
cmd = result.name
|
|
717
|
+
args = result.args
|
|
718
|
+
|
|
719
|
+
# --- Exit commands ---
|
|
720
|
+
if cmd in ("quit", "exit", "q"):
|
|
721
|
+
return None
|
|
722
|
+
|
|
723
|
+
# --- Help ---
|
|
724
|
+
if cmd == "help":
|
|
725
|
+
return render_help()
|
|
726
|
+
|
|
727
|
+
# --- Clear ---
|
|
728
|
+
if cmd == "clear":
|
|
729
|
+
session.messages.clear()
|
|
730
|
+
return "Session cleared."
|
|
731
|
+
|
|
732
|
+
# --- Cost ---
|
|
733
|
+
if cmd == "cost":
|
|
734
|
+
lines = runtime.usage_tracker.total.summary_lines("Session total", model=runtime.model)
|
|
735
|
+
return "\n".join(lines)
|
|
736
|
+
|
|
737
|
+
# --- Status ---
|
|
738
|
+
if cmd == "status":
|
|
739
|
+
return _format_status(runtime, session)
|
|
740
|
+
|
|
741
|
+
# --- Model ---
|
|
742
|
+
if cmd == "model":
|
|
743
|
+
if args.strip():
|
|
744
|
+
new_model = resolve_model_alias(args.strip())
|
|
745
|
+
runtime.model = new_model
|
|
746
|
+
return f"Model set to: {new_model}"
|
|
747
|
+
return f"Current model: {runtime.model}"
|
|
748
|
+
|
|
749
|
+
# --- Models (list available) ---
|
|
750
|
+
if cmd == "models":
|
|
751
|
+
return await _list_models(runtime)
|
|
752
|
+
|
|
753
|
+
# --- Compact ---
|
|
754
|
+
if cmd == "compact":
|
|
755
|
+
tokens_before = estimate_session_tokens(session)
|
|
756
|
+
compaction_config = CompactionConfig()
|
|
757
|
+
if args.strip():
|
|
758
|
+
try:
|
|
759
|
+
compaction_config.max_tokens = int(args.strip())
|
|
760
|
+
except ValueError:
|
|
761
|
+
return "Usage: /compact [max_tokens]"
|
|
762
|
+
cr = compact_session(session, compaction_config)
|
|
763
|
+
if cr is None:
|
|
764
|
+
return f"No compaction needed (estimated {tokens_before} tokens)."
|
|
765
|
+
return (
|
|
766
|
+
f"Compacted: removed {cr.removed_count} messages, "
|
|
767
|
+
f"{cr.estimated_tokens_before} -> {cr.estimated_tokens_after} tokens."
|
|
768
|
+
)
|
|
769
|
+
|
|
770
|
+
# --- Permissions ---
|
|
771
|
+
if cmd == "permissions":
|
|
772
|
+
if args.strip():
|
|
773
|
+
try:
|
|
774
|
+
new_mode = PermissionMode(args.strip())
|
|
775
|
+
runtime.permission_policy.mode = new_mode
|
|
776
|
+
return f"Permission mode set to: {new_mode.value}"
|
|
777
|
+
except ValueError:
|
|
778
|
+
valid = ", ".join(m.value for m in PermissionMode)
|
|
779
|
+
return f"Invalid mode. Valid modes: {valid}"
|
|
780
|
+
return f"Current permission mode: {runtime.permission_policy.mode.value}"
|
|
781
|
+
|
|
782
|
+
# --- Config ---
|
|
783
|
+
if cmd == "config":
|
|
784
|
+
return _format_config(config)
|
|
785
|
+
|
|
786
|
+
# --- MCP ---
|
|
787
|
+
if cmd == "mcp":
|
|
788
|
+
return handle_mcp_command(args)
|
|
789
|
+
|
|
790
|
+
# --- Plugins ---
|
|
791
|
+
if cmd == "plugins":
|
|
792
|
+
return handle_plugins_command(args, plugin_manager)
|
|
793
|
+
|
|
794
|
+
# --- Skills ---
|
|
795
|
+
if cmd == "skills":
|
|
796
|
+
return handle_skills_command(args)
|
|
797
|
+
|
|
798
|
+
# --- Agents ---
|
|
799
|
+
if cmd == "agents":
|
|
800
|
+
return handle_agents_command(args)
|
|
801
|
+
|
|
802
|
+
# --- Memory ---
|
|
803
|
+
if cmd == "memory":
|
|
804
|
+
return _handle_memory_command(args)
|
|
805
|
+
|
|
806
|
+
# --- Init ---
|
|
807
|
+
if cmd == "init":
|
|
808
|
+
return _handle_init_command()
|
|
809
|
+
|
|
810
|
+
# --- Doctor ---
|
|
811
|
+
if cmd == "doctor":
|
|
812
|
+
return _run_doctor_checks()
|
|
813
|
+
|
|
814
|
+
# --- Resume ---
|
|
815
|
+
if cmd == "resume":
|
|
816
|
+
return _handle_resume_in_repl(args, session, runtime)
|
|
817
|
+
|
|
818
|
+
# --- Version ---
|
|
819
|
+
if cmd == "version":
|
|
820
|
+
return f"axion-code {__version__}"
|
|
821
|
+
|
|
822
|
+
# --- Sandbox ---
|
|
823
|
+
if cmd == "sandbox":
|
|
824
|
+
status = detect_sandbox()
|
|
825
|
+
return (
|
|
826
|
+
f"Sandbox status:\n"
|
|
827
|
+
f" Available: {status.available}\n"
|
|
828
|
+
f" Enabled: {status.enabled}\n"
|
|
829
|
+
f" Platform: {status.platform}\n"
|
|
830
|
+
f" Details: {status.details}"
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
# --- Diff ---
|
|
834
|
+
if cmd == "diff":
|
|
835
|
+
return _handle_diff_command(args, session)
|
|
836
|
+
|
|
837
|
+
# --- Export ---
|
|
838
|
+
if cmd == "export":
|
|
839
|
+
return _handle_export_command(args, session)
|
|
840
|
+
|
|
841
|
+
# --- Session ---
|
|
842
|
+
if cmd == "session":
|
|
843
|
+
return _handle_session_command(args, session)
|
|
844
|
+
|
|
845
|
+
# --- Commit ---
|
|
846
|
+
if cmd == "commit":
|
|
847
|
+
return handle_commit_command(args)
|
|
848
|
+
|
|
849
|
+
# --- Undo ---
|
|
850
|
+
if cmd == "undo":
|
|
851
|
+
return handle_undo_command(args)
|
|
852
|
+
|
|
853
|
+
# --- Review ---
|
|
854
|
+
if cmd == "review":
|
|
855
|
+
review_prompt = handle_review_command(args)
|
|
856
|
+
if review_prompt.startswith("REVIEW_MODE:"):
|
|
857
|
+
return "__RUN_TURN__:" + review_prompt
|
|
858
|
+
return review_prompt
|
|
859
|
+
|
|
860
|
+
# --- Test ---
|
|
861
|
+
if cmd == "test":
|
|
862
|
+
test_prompt = handle_test_command(args)
|
|
863
|
+
if test_prompt.startswith("TEST_MODE:"):
|
|
864
|
+
return "__RUN_TURN__:" + test_prompt
|
|
865
|
+
return test_prompt
|
|
866
|
+
|
|
867
|
+
# --- Init project ---
|
|
868
|
+
if cmd in ("init-project", "scaffold"):
|
|
869
|
+
init_prompt = handle_init_project_command(args)
|
|
870
|
+
if init_prompt.startswith("INIT_PROJECT_MODE:"):
|
|
871
|
+
return "__RUN_TURN__:" + init_prompt
|
|
872
|
+
return init_prompt
|
|
873
|
+
|
|
874
|
+
# --- Share ---
|
|
875
|
+
if cmd == "share":
|
|
876
|
+
from axion.runtime.sharing import handle_share_command
|
|
877
|
+
return handle_share_command(args, session)
|
|
878
|
+
|
|
879
|
+
# --- Plan mode ---
|
|
880
|
+
if cmd == "plan":
|
|
881
|
+
return _handle_plan_command(args, runtime, session)
|
|
882
|
+
|
|
883
|
+
# --- Context (token usage) ---
|
|
884
|
+
if cmd == "context":
|
|
885
|
+
tokens = estimate_session_tokens(session)
|
|
886
|
+
model_window = 200_000 # Default
|
|
887
|
+
pct = (tokens / model_window * 100) if model_window > 0 else 0
|
|
888
|
+
bar_len = int(pct / 2) # 50 chars max
|
|
889
|
+
bar = "█" * bar_len + "░" * (50 - bar_len)
|
|
890
|
+
return (
|
|
891
|
+
f"Context window usage:\n"
|
|
892
|
+
f" Model: {runtime.model}\n"
|
|
893
|
+
f" Estimated tokens: {tokens:,} / {model_window:,}\n"
|
|
894
|
+
f" [{bar}] {pct:.1f}%\n"
|
|
895
|
+
f" Messages: {session.message_count()}"
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
# --- Branch ---
|
|
899
|
+
if cmd == "branch":
|
|
900
|
+
if args.strip():
|
|
901
|
+
from axion.runtime.git import git_create_branch
|
|
902
|
+
try:
|
|
903
|
+
git_create_branch(Path.cwd(), args.strip())
|
|
904
|
+
return f"Switched to branch: {args.strip()}"
|
|
905
|
+
except Exception as exc:
|
|
906
|
+
return f"Branch failed: {exc}"
|
|
907
|
+
branch = _git_branch() or "unknown"
|
|
908
|
+
return f"Current branch: {branch}"
|
|
909
|
+
|
|
910
|
+
# --- Hooks ---
|
|
911
|
+
if cmd == "hooks":
|
|
912
|
+
cfg = config or _load_config()
|
|
913
|
+
hooks = cfg.feature_config.hooks
|
|
914
|
+
lines_out = ["Configured hooks:"]
|
|
915
|
+
pre = hooks.pre_tool_use
|
|
916
|
+
post = hooks.post_tool_use
|
|
917
|
+
fail = hooks.post_tool_use_failure
|
|
918
|
+
if not pre and not post and not fail:
|
|
919
|
+
return "No hooks configured."
|
|
920
|
+
if pre:
|
|
921
|
+
lines_out.append(f" Pre-tool-use ({len(pre)}):")
|
|
922
|
+
for h in pre:
|
|
923
|
+
lines_out.append(f" {h.command}")
|
|
924
|
+
if post:
|
|
925
|
+
lines_out.append(f" Post-tool-use ({len(post)}):")
|
|
926
|
+
for h in post:
|
|
927
|
+
lines_out.append(f" {h.command}")
|
|
928
|
+
if fail:
|
|
929
|
+
lines_out.append(f" Post-failure ({len(fail)}):")
|
|
930
|
+
for h in fail:
|
|
931
|
+
lines_out.append(f" {h.command}")
|
|
932
|
+
return "\n".join(lines_out)
|
|
933
|
+
|
|
934
|
+
# --- Copy ---
|
|
935
|
+
if cmd == "copy":
|
|
936
|
+
# Copy last assistant response to clipboard
|
|
937
|
+
last_text = ""
|
|
938
|
+
for msg in reversed(session.messages):
|
|
939
|
+
if msg.role.value == "assistant":
|
|
940
|
+
for block in msg.blocks:
|
|
941
|
+
if hasattr(block, "text"):
|
|
942
|
+
last_text = block.text
|
|
943
|
+
break
|
|
944
|
+
if last_text:
|
|
945
|
+
break
|
|
946
|
+
if not last_text:
|
|
947
|
+
return "No assistant response to copy."
|
|
948
|
+
try:
|
|
949
|
+
import subprocess as _sp
|
|
950
|
+
process = _sp.Popen(["clip"], stdin=_sp.PIPE)
|
|
951
|
+
process.communicate(last_text.encode("utf-8"))
|
|
952
|
+
return f"Copied {len(last_text)} chars to clipboard."
|
|
953
|
+
except Exception:
|
|
954
|
+
return f"Clipboard not available. Last response ({len(last_text)} chars):\n{last_text[:200]}..."
|
|
955
|
+
|
|
956
|
+
# --- Rename ---
|
|
957
|
+
if cmd == "rename":
|
|
958
|
+
new_name = args.strip()
|
|
959
|
+
if not new_name:
|
|
960
|
+
return "Usage: /rename <new_session_name>"
|
|
961
|
+
old_id = session.session_id
|
|
962
|
+
session.session_id = new_name
|
|
963
|
+
session.with_persistence_path(_session_path_for_id(new_name))
|
|
964
|
+
return f"Session renamed: {old_id} → {new_name}"
|
|
965
|
+
|
|
966
|
+
# --- Files ---
|
|
967
|
+
if cmd == "files":
|
|
968
|
+
# List files that were read/written in this session
|
|
969
|
+
files_seen: set[str] = set()
|
|
970
|
+
for msg in session.messages:
|
|
971
|
+
for block in msg.blocks:
|
|
972
|
+
if hasattr(block, "name") and hasattr(block, "input"):
|
|
973
|
+
try:
|
|
974
|
+
import json as _j
|
|
975
|
+
params = _j.loads(block.input) if block.input else {}
|
|
976
|
+
fp = params.get("file_path") or params.get("path") or params.get("pattern")
|
|
977
|
+
if fp:
|
|
978
|
+
files_seen.add(str(fp))
|
|
979
|
+
except Exception:
|
|
980
|
+
pass
|
|
981
|
+
if not files_seen:
|
|
982
|
+
return "No files referenced in this session."
|
|
983
|
+
lines_out = [f"Files referenced ({len(files_seen)}):"]
|
|
984
|
+
for f in sorted(files_seen):
|
|
985
|
+
lines_out.append(f" {f}")
|
|
986
|
+
return "\n".join(lines_out)
|
|
987
|
+
|
|
988
|
+
# --- Summary ---
|
|
989
|
+
if cmd == "summary":
|
|
990
|
+
return "__RUN_TURN__:Summarize this entire conversation so far in a brief paragraph. What topics were discussed, what was accomplished, and what's the current state?"
|
|
991
|
+
|
|
992
|
+
# --- Stats ---
|
|
993
|
+
if cmd == "stats":
|
|
994
|
+
total = runtime.usage_tracker.total
|
|
995
|
+
from axion.runtime.usage import pricing_for_model
|
|
996
|
+
mp = pricing_for_model(runtime.model)
|
|
997
|
+
cost = total.estimate_cost_usd_with_pricing(mp) if mp else total.estimate_cost_usd()
|
|
998
|
+
return (
|
|
999
|
+
f"Session statistics:\n"
|
|
1000
|
+
f" Model: {runtime.model}\n"
|
|
1001
|
+
f" Turns: {runtime.usage_tracker.turn_count}\n"
|
|
1002
|
+
f" Messages: {session.message_count()}\n"
|
|
1003
|
+
f" Input tokens: {total.input_tokens:,}\n"
|
|
1004
|
+
f" Output tokens: {total.output_tokens:,}\n"
|
|
1005
|
+
f" Cache write: {total.cache_creation_input_tokens:,}\n"
|
|
1006
|
+
f" Cache read: {total.cache_read_input_tokens:,}\n"
|
|
1007
|
+
f" Total tokens: {total.total_tokens():,}\n"
|
|
1008
|
+
f" Total cost: ${cost.total_cost_usd():.4f}\n"
|
|
1009
|
+
f" Session ID: {session.session_id}"
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
# --- Security review ---
|
|
1013
|
+
if cmd == "security-review":
|
|
1014
|
+
file_target = args.strip() or ""
|
|
1015
|
+
target_desc = f"the file {file_target}" if file_target else "the recent changes"
|
|
1016
|
+
return (
|
|
1017
|
+
f"__RUN_TURN__:SECURITY_REVIEW_MODE: Perform a security audit of {target_desc}.\n\n"
|
|
1018
|
+
"Check for:\n"
|
|
1019
|
+
"1. SQL injection vulnerabilities\n"
|
|
1020
|
+
"2. XSS (cross-site scripting)\n"
|
|
1021
|
+
"3. Authentication/authorization flaws\n"
|
|
1022
|
+
"4. Sensitive data exposure (API keys, passwords in code)\n"
|
|
1023
|
+
"5. Input validation issues\n"
|
|
1024
|
+
"6. Insecure dependencies\n"
|
|
1025
|
+
"7. CSRF vulnerabilities\n"
|
|
1026
|
+
"8. Path traversal\n\n"
|
|
1027
|
+
"Rate each finding as CRITICAL, HIGH, MEDIUM, or LOW."
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
if cmd == "image":
|
|
1031
|
+
return _handle_image_command(args, session, runtime)
|
|
1032
|
+
|
|
1033
|
+
if cmd == "auth-mode" or cmd == "auth":
|
|
1034
|
+
return await _handle_auth_mode_command(args, runtime)
|
|
1035
|
+
|
|
1036
|
+
return f"Command /{cmd} recognized but has no handler yet."
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
async def _rebuild_provider_for_runtime(runtime: ConversationRuntime) -> str:
|
|
1040
|
+
"""Recreate runtime.provider with the current env/credential state.
|
|
1041
|
+
|
|
1042
|
+
Used by /auth-mode and /model so the auth switch takes effect right
|
|
1043
|
+
away without needing to restart axion.
|
|
1044
|
+
"""
|
|
1045
|
+
from axion.api.client import ProviderClient
|
|
1046
|
+
try:
|
|
1047
|
+
# Close the existing client first to release any open connections
|
|
1048
|
+
try:
|
|
1049
|
+
await runtime.provider.close()
|
|
1050
|
+
except Exception:
|
|
1051
|
+
pass
|
|
1052
|
+
runtime.provider = ProviderClient.from_model(runtime.model)
|
|
1053
|
+
return ""
|
|
1054
|
+
except Exception as exc:
|
|
1055
|
+
return f" [yellow]Note: failed to rebuild provider client: {exc}[/yellow]"
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
async def _handle_auth_mode_command(args: str, runtime: ConversationRuntime) -> str:
|
|
1059
|
+
"""Show or switch auth mode for both Anthropic and OpenAI."""
|
|
1060
|
+
from axion.runtime.claude_subscription import has_subscription_credentials
|
|
1061
|
+
from axion.runtime.openai_subscription import (
|
|
1062
|
+
get_openai_subscription_plan,
|
|
1063
|
+
has_openai_subscription_credentials,
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
arg = args.strip().lower()
|
|
1067
|
+
forced = os.environ.get("AXION_AUTH_MODE", "").lower()
|
|
1068
|
+
|
|
1069
|
+
# Anthropic credentials
|
|
1070
|
+
has_claude_sub = has_subscription_credentials()
|
|
1071
|
+
claude_key_path = Path.home() / ".axion" / "credentials" / "anthropic.key"
|
|
1072
|
+
has_claude_api = claude_key_path.exists() or bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
1073
|
+
|
|
1074
|
+
# OpenAI credentials
|
|
1075
|
+
has_chatgpt_sub = has_openai_subscription_credentials()
|
|
1076
|
+
chatgpt_plan = get_openai_subscription_plan() if has_chatgpt_sub else None
|
|
1077
|
+
openai_key_path = Path.home() / ".axion" / "credentials" / "openai.key"
|
|
1078
|
+
has_openai_api = openai_key_path.exists() or bool(os.environ.get("OPENAI_API_KEY"))
|
|
1079
|
+
|
|
1080
|
+
if arg in ("status", ""):
|
|
1081
|
+
lines = ["[bold]Auth Status[/bold]", ""]
|
|
1082
|
+
|
|
1083
|
+
# Anthropic
|
|
1084
|
+
lines.append("[bold #64ffda]Anthropic (Claude)[/bold #64ffda]")
|
|
1085
|
+
if forced == "api":
|
|
1086
|
+
lines.append(" Active: [yellow]API key[/yellow] (forced via AXION_AUTH_MODE=api)")
|
|
1087
|
+
elif has_claude_sub:
|
|
1088
|
+
lines.append(" Active: [green]Subscription (Pro/Max)[/green]")
|
|
1089
|
+
elif has_claude_api:
|
|
1090
|
+
lines.append(" Active: [cyan]API key[/cyan] (pay-per-token)")
|
|
1091
|
+
else:
|
|
1092
|
+
lines.append(" Active: [dim]not authenticated[/dim]")
|
|
1093
|
+
lines.append(f" Subscription: {'[green]yes[/green]' if has_claude_sub else '[dim]no[/dim]'}")
|
|
1094
|
+
lines.append(f" API key: {'[green]yes[/green]' if has_claude_api else '[dim]no[/dim]'}")
|
|
1095
|
+
lines.append("")
|
|
1096
|
+
|
|
1097
|
+
# OpenAI
|
|
1098
|
+
lines.append("[bold #64ffda]OpenAI (Codex / ChatGPT)[/bold #64ffda]")
|
|
1099
|
+
if forced == "api":
|
|
1100
|
+
lines.append(" Active: [yellow]API key[/yellow] (forced via AXION_AUTH_MODE=api)")
|
|
1101
|
+
elif has_chatgpt_sub:
|
|
1102
|
+
plan_text = f"ChatGPT {chatgpt_plan}" if chatgpt_plan else "ChatGPT subscription"
|
|
1103
|
+
lines.append(f" Active: [green]{plan_text}[/green]")
|
|
1104
|
+
elif has_openai_api:
|
|
1105
|
+
lines.append(" Active: [cyan]API key[/cyan] (pay-per-token)")
|
|
1106
|
+
else:
|
|
1107
|
+
lines.append(" Active: [dim]not authenticated[/dim]")
|
|
1108
|
+
lines.append(f" Subscription: {'[green]yes[/green]' if has_chatgpt_sub else '[dim]no[/dim]'}")
|
|
1109
|
+
lines.append(f" API key: {'[green]yes[/green]' if has_openai_api else '[dim]no[/dim]'}")
|
|
1110
|
+
lines.append("")
|
|
1111
|
+
|
|
1112
|
+
lines.append("[dim]Switch:[/dim]")
|
|
1113
|
+
lines.append("[dim] /auth-mode subscription — use subscriptions when present[/dim]")
|
|
1114
|
+
lines.append("[dim] /auth-mode api — force API key billing[/dim]")
|
|
1115
|
+
return "\n".join(lines)
|
|
1116
|
+
|
|
1117
|
+
if arg in ("subscription", "sub", "pro", "max"):
|
|
1118
|
+
if not has_claude_sub and not has_chatgpt_sub:
|
|
1119
|
+
return (
|
|
1120
|
+
"[yellow]No subscriptions saved.[/yellow]\n"
|
|
1121
|
+
"Run one of:\n"
|
|
1122
|
+
" [cyan]axion login --subscription[/cyan] (Claude Pro/Max)\n"
|
|
1123
|
+
" [cyan]axion login --subscription --provider openai[/cyan] (ChatGPT Plus/Pro/Business)"
|
|
1124
|
+
)
|
|
1125
|
+
if "AXION_AUTH_MODE" in os.environ:
|
|
1126
|
+
del os.environ["AXION_AUTH_MODE"]
|
|
1127
|
+
rebuild_note = await _rebuild_provider_for_runtime(runtime)
|
|
1128
|
+
active: list[str] = []
|
|
1129
|
+
if has_claude_sub:
|
|
1130
|
+
active.append("Claude Pro/Max")
|
|
1131
|
+
if has_chatgpt_sub:
|
|
1132
|
+
active.append(f"ChatGPT {chatgpt_plan or 'subscription'}")
|
|
1133
|
+
msg = f"Subscription mode active for: [green]{', '.join(active)}[/green]."
|
|
1134
|
+
if rebuild_note:
|
|
1135
|
+
msg += "\n" + rebuild_note
|
|
1136
|
+
return msg
|
|
1137
|
+
|
|
1138
|
+
if arg in ("api", "apikey", "api-key"):
|
|
1139
|
+
os.environ["AXION_AUTH_MODE"] = "api"
|
|
1140
|
+
rebuild_note = await _rebuild_provider_for_runtime(runtime)
|
|
1141
|
+
msg = "Switched to [cyan]API key[/cyan] auth (both providers) and rebuilt the provider client."
|
|
1142
|
+
if rebuild_note:
|
|
1143
|
+
msg += "\n" + rebuild_note
|
|
1144
|
+
return msg
|
|
1145
|
+
|
|
1146
|
+
return f"Unknown auth mode: {arg}. Use /auth-mode [status|subscription|api]"
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
async def _list_models(runtime: ConversationRuntime) -> str:
|
|
1150
|
+
"""List models across providers based on which credentials are saved."""
|
|
1151
|
+
from pathlib import Path as _P
|
|
1152
|
+
|
|
1153
|
+
cred_dir = _P.home() / ".axion" / "credentials"
|
|
1154
|
+
sections: list[str] = []
|
|
1155
|
+
current = runtime.model
|
|
1156
|
+
|
|
1157
|
+
def _line(name: str, alias: str = "", note: str = "") -> str:
|
|
1158
|
+
marker = "[bold #00d4aa]●[/bold #00d4aa]" if name == current or alias == current else " "
|
|
1159
|
+
bits = [f" {marker} [bold]{name}[/bold]"]
|
|
1160
|
+
if alias:
|
|
1161
|
+
bits.append(f"[dim]({alias})[/dim]")
|
|
1162
|
+
if note:
|
|
1163
|
+
bits.append(f"[dim]— {note}[/dim]")
|
|
1164
|
+
return " ".join(bits)
|
|
1165
|
+
|
|
1166
|
+
# Anthropic — Claude
|
|
1167
|
+
has_subscription = (cred_dir / "anthropic-oauth.json").exists()
|
|
1168
|
+
has_anthropic_key = (cred_dir / "anthropic.key").exists() or bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
1169
|
+
if has_subscription or has_anthropic_key:
|
|
1170
|
+
auth_note = "Pro/Max subscription" if has_subscription else "API key"
|
|
1171
|
+
sections.append(f"[bold #64ffda]Anthropic[/bold #64ffda] [dim]({auth_note})[/dim]")
|
|
1172
|
+
sections.append(_line("claude-opus-4-7", "opus"))
|
|
1173
|
+
sections.append(_line("claude-sonnet-4-6", "sonnet"))
|
|
1174
|
+
sections.append(_line("claude-haiku-4-5", "haiku"))
|
|
1175
|
+
sections.append("")
|
|
1176
|
+
|
|
1177
|
+
# OpenAI (Chat Completions)
|
|
1178
|
+
has_openai_key = (cred_dir / "openai.key").exists() or os.environ.get("OPENAI_API_KEY")
|
|
1179
|
+
if has_openai_key:
|
|
1180
|
+
sections.append("[bold #64ffda]OpenAI[/bold #64ffda] [dim](API key — Chat Completions)[/dim]")
|
|
1181
|
+
sections.append(_line("gpt-5"))
|
|
1182
|
+
sections.append(_line("gpt-4o"))
|
|
1183
|
+
sections.append(_line("gpt-4o-mini"))
|
|
1184
|
+
sections.append(_line("o3"))
|
|
1185
|
+
sections.append(_line("o3-mini"))
|
|
1186
|
+
sections.append(_line("o1"))
|
|
1187
|
+
sections.append("")
|
|
1188
|
+
|
|
1189
|
+
# OpenAI Codex (Responses API). Show if user has either an OpenAI API key
|
|
1190
|
+
# OR a ChatGPT subscription saved.
|
|
1191
|
+
has_chatgpt_sub = (cred_dir / "openai-oauth.json").exists()
|
|
1192
|
+
if has_openai_key or has_chatgpt_sub:
|
|
1193
|
+
if has_chatgpt_sub:
|
|
1194
|
+
try:
|
|
1195
|
+
from axion.runtime.openai_subscription import get_openai_subscription_plan
|
|
1196
|
+
plan = get_openai_subscription_plan() or "ChatGPT subscription"
|
|
1197
|
+
badge = f"[dim](ChatGPT {plan} subscription)[/dim]"
|
|
1198
|
+
except Exception:
|
|
1199
|
+
badge = "[dim](ChatGPT subscription)[/dim]"
|
|
1200
|
+
else:
|
|
1201
|
+
badge = "[dim](API key — Responses API, agent-tuned for coding)[/dim]"
|
|
1202
|
+
sections.append(f"[bold #64ffda]OpenAI Codex[/bold #64ffda] {badge}")
|
|
1203
|
+
sections.append(_line("gpt-5-codex", "codex"))
|
|
1204
|
+
sections.append(_line("gpt-5-codex-mini", "codex-mini"))
|
|
1205
|
+
sections.append("")
|
|
1206
|
+
|
|
1207
|
+
# xAI
|
|
1208
|
+
if (cred_dir / "xai.key").exists() or os.environ.get("XAI_API_KEY"):
|
|
1209
|
+
sections.append("[bold #64ffda]xAI[/bold #64ffda] [dim](API key)[/dim]")
|
|
1210
|
+
sections.append(_line("grok-2"))
|
|
1211
|
+
sections.append("")
|
|
1212
|
+
|
|
1213
|
+
# Ollama (local) — best effort, don't crash if not installed
|
|
1214
|
+
try:
|
|
1215
|
+
from axion.api.ollama import OllamaClient
|
|
1216
|
+
client = OllamaClient()
|
|
1217
|
+
models = await client.list_models()
|
|
1218
|
+
if models:
|
|
1219
|
+
sections.append("[bold #64ffda]Ollama[/bold #64ffda] [dim](local)[/dim]")
|
|
1220
|
+
for m in models:
|
|
1221
|
+
size_str = ""
|
|
1222
|
+
size_attr = getattr(m, "size", None)
|
|
1223
|
+
if size_attr:
|
|
1224
|
+
size_str = f"{size_attr}" if isinstance(size_attr, str) else f"{int(size_attr) // (1024**3)}GB"
|
|
1225
|
+
sections.append(_line(m.name, note=size_str))
|
|
1226
|
+
sections.append("")
|
|
1227
|
+
except Exception:
|
|
1228
|
+
pass
|
|
1229
|
+
|
|
1230
|
+
if not sections:
|
|
1231
|
+
return (
|
|
1232
|
+
"No providers configured.\n\n"
|
|
1233
|
+
"Set up one with:\n"
|
|
1234
|
+
" axion login (Anthropic — API key or Pro/Max)\n"
|
|
1235
|
+
" axion login --provider openai\n"
|
|
1236
|
+
" axion login --provider xai"
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
sections.insert(0, f"Current: [bold]{current}[/bold]\n")
|
|
1240
|
+
sections.append("[dim]Switch with: /model <name>[/dim]")
|
|
1241
|
+
return "\n".join(sections)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def _handle_image_command(
|
|
1245
|
+
args: str, session: Session, runtime: ConversationRuntime
|
|
1246
|
+
) -> str:
|
|
1247
|
+
"""Handle /image — grab from clipboard or load from file path."""
|
|
1248
|
+
from axion.runtime.image import (
|
|
1249
|
+
grab_clipboard_image,
|
|
1250
|
+
image_size_description,
|
|
1251
|
+
is_image_path,
|
|
1252
|
+
load_image_file,
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
image_data: tuple[str, str] | None = None
|
|
1256
|
+
prompt_text = ""
|
|
1257
|
+
|
|
1258
|
+
if args.strip():
|
|
1259
|
+
# Check if first arg is an image file path
|
|
1260
|
+
parts = args.strip().split(maxsplit=1)
|
|
1261
|
+
candidate = parts[0]
|
|
1262
|
+
if is_image_path(candidate):
|
|
1263
|
+
image_data = load_image_file(candidate)
|
|
1264
|
+
prompt_text = parts[1] if len(parts) > 1 else "Describe this image."
|
|
1265
|
+
if image_data is None:
|
|
1266
|
+
return f"Failed to load image: {candidate}"
|
|
1267
|
+
else:
|
|
1268
|
+
# Treat entire args as prompt, grab from clipboard
|
|
1269
|
+
prompt_text = args.strip()
|
|
1270
|
+
image_data = grab_clipboard_image()
|
|
1271
|
+
if image_data is None:
|
|
1272
|
+
return "No image found on clipboard. Copy an image first, or use: /image path/to/file.png"
|
|
1273
|
+
else:
|
|
1274
|
+
# No args — grab from clipboard
|
|
1275
|
+
image_data = grab_clipboard_image()
|
|
1276
|
+
prompt_text = "What do you see in this image? Describe it and help me work with it."
|
|
1277
|
+
if image_data is None:
|
|
1278
|
+
return "No image on clipboard. Copy a screenshot first, or use: /image path/to/file.png [prompt]"
|
|
1279
|
+
|
|
1280
|
+
media_type, b64 = image_data
|
|
1281
|
+
size_str = image_size_description(b64)
|
|
1282
|
+
console.print(f"[dim]Attached image ({media_type}, {size_str})[/dim]")
|
|
1283
|
+
|
|
1284
|
+
# Store image in pending images for the next run_turn
|
|
1285
|
+
if not hasattr(runtime, "_pending_images"):
|
|
1286
|
+
runtime._pending_images = [] # type: ignore[attr-defined]
|
|
1287
|
+
runtime._pending_images.append((media_type, b64)) # type: ignore[attr-defined]
|
|
1288
|
+
|
|
1289
|
+
return f"__RUN_TURN__:{prompt_text}"
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _handle_inline_image(user_input: str, runtime: ConversationRuntime) -> str:
|
|
1293
|
+
"""Detect /image anywhere in the input (not just as a slash command).
|
|
1294
|
+
|
|
1295
|
+
If the user types "this looks generic /image" or "fix this /image path.png",
|
|
1296
|
+
strip the /image part, grab the clipboard or file, and attach it.
|
|
1297
|
+
Returns the cleaned text (without /image).
|
|
1298
|
+
"""
|
|
1299
|
+
import re
|
|
1300
|
+
|
|
1301
|
+
# Match /image optionally followed by a file path
|
|
1302
|
+
match = re.search(r'\s*/image\s*([\w./\\:-]*\.(?:png|jpg|jpeg|gif|webp|bmp))?\s*', user_input, re.IGNORECASE)
|
|
1303
|
+
if not match:
|
|
1304
|
+
return user_input
|
|
1305
|
+
|
|
1306
|
+
from axion.runtime.image import (
|
|
1307
|
+
grab_clipboard_image,
|
|
1308
|
+
image_size_description,
|
|
1309
|
+
load_image_file,
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
file_arg = match.group(1)
|
|
1313
|
+
image_data: tuple[str, str] | None = None
|
|
1314
|
+
|
|
1315
|
+
if file_arg:
|
|
1316
|
+
image_data = load_image_file(file_arg)
|
|
1317
|
+
if image_data is None:
|
|
1318
|
+
console.print(f"[yellow]Could not load image: {file_arg}[/yellow]")
|
|
1319
|
+
else:
|
|
1320
|
+
image_data = grab_clipboard_image()
|
|
1321
|
+
if image_data is None:
|
|
1322
|
+
console.print("[yellow]No image on clipboard. Copy a screenshot first.[/yellow]")
|
|
1323
|
+
|
|
1324
|
+
if image_data:
|
|
1325
|
+
media_type, b64 = image_data
|
|
1326
|
+
size_str = image_size_description(b64)
|
|
1327
|
+
console.print(f"[dim]Attached image ({media_type}, {size_str})[/dim]")
|
|
1328
|
+
|
|
1329
|
+
if not hasattr(runtime, "_pending_images"):
|
|
1330
|
+
runtime._pending_images = [] # type: ignore[attr-defined]
|
|
1331
|
+
runtime._pending_images.append((media_type, b64)) # type: ignore[attr-defined]
|
|
1332
|
+
|
|
1333
|
+
# Remove the /image part from the text
|
|
1334
|
+
cleaned = user_input[:match.start()] + user_input[match.end():]
|
|
1335
|
+
cleaned = cleaned.strip()
|
|
1336
|
+
|
|
1337
|
+
# If text is now empty, add a default prompt
|
|
1338
|
+
if not cleaned:
|
|
1339
|
+
cleaned = "What do you see in this image? Describe it and help me work with it."
|
|
1340
|
+
|
|
1341
|
+
return cleaned
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _extract_image_paths(user_input: str) -> tuple[str, list[tuple[str, str]]]:
|
|
1345
|
+
"""Detect image file paths in user input and load them.
|
|
1346
|
+
|
|
1347
|
+
Returns (cleaned_input, list_of_(media_type, base64_data)).
|
|
1348
|
+
Supports: screenshot.png, ./img.jpg, C:\\path\\to\\image.png, etc.
|
|
1349
|
+
"""
|
|
1350
|
+
import re
|
|
1351
|
+
|
|
1352
|
+
from axion.runtime.image import (
|
|
1353
|
+
image_size_description,
|
|
1354
|
+
load_image_file,
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
images: list[tuple[str, str]] = []
|
|
1358
|
+
|
|
1359
|
+
# Match file paths that end with image extensions
|
|
1360
|
+
pattern = r'(?:^|\s)((?:[A-Za-z]:\\|\.{0,2}[/\\])?[\w./\\-]+\.(?:png|jpg|jpeg|gif|webp|bmp))(?:\s|$)'
|
|
1361
|
+
matches = re.findall(pattern, user_input, re.IGNORECASE)
|
|
1362
|
+
|
|
1363
|
+
for match in matches:
|
|
1364
|
+
result = load_image_file(match)
|
|
1365
|
+
if result:
|
|
1366
|
+
media_type, b64 = result
|
|
1367
|
+
images.append((media_type, b64))
|
|
1368
|
+
size_str = image_size_description(b64)
|
|
1369
|
+
console.print(f"[dim]Attached image: {match} ({media_type}, {size_str})[/dim]")
|
|
1370
|
+
|
|
1371
|
+
return user_input, images
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def _inject_file_context(user_input: str) -> str:
|
|
1375
|
+
"""Scan user input for @file references and inject file contents.
|
|
1376
|
+
|
|
1377
|
+
Example: "@src/auth.py fix the bug" becomes:
|
|
1378
|
+
"[Context from @src/auth.py]\n1 def login():\n...\n\nfix the bug"
|
|
1379
|
+
"""
|
|
1380
|
+
import re
|
|
1381
|
+
|
|
1382
|
+
# Find all @path references (word boundary after @, path chars until space)
|
|
1383
|
+
pattern = r"(?:^|\s)@([\w./\\-]+)"
|
|
1384
|
+
matches = re.findall(pattern, user_input)
|
|
1385
|
+
|
|
1386
|
+
if not matches:
|
|
1387
|
+
return user_input
|
|
1388
|
+
|
|
1389
|
+
context_parts: list[str] = []
|
|
1390
|
+
clean_input = user_input
|
|
1391
|
+
|
|
1392
|
+
for file_ref in matches:
|
|
1393
|
+
file_path = Path(file_ref)
|
|
1394
|
+
if file_path.exists() and file_path.is_file():
|
|
1395
|
+
try:
|
|
1396
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
1397
|
+
# Add line numbers
|
|
1398
|
+
numbered = "\n".join(
|
|
1399
|
+
f"{i}\t{line}" for i, line in enumerate(content.splitlines()[:100], 1)
|
|
1400
|
+
)
|
|
1401
|
+
if len(content.splitlines()) > 100:
|
|
1402
|
+
numbered += f"\n... ({len(content.splitlines())} lines total)"
|
|
1403
|
+
context_parts.append(f"[Context from @{file_ref}]\n{numbered}")
|
|
1404
|
+
# Remove the @ref from the user input
|
|
1405
|
+
clean_input = clean_input.replace(f"@{file_ref}", "").strip()
|
|
1406
|
+
except OSError:
|
|
1407
|
+
pass
|
|
1408
|
+
elif file_path.exists() and file_path.is_dir():
|
|
1409
|
+
# List directory contents
|
|
1410
|
+
try:
|
|
1411
|
+
entries = sorted(file_path.iterdir())[:30]
|
|
1412
|
+
listing = "\n".join(
|
|
1413
|
+
f" {'📁 ' if e.is_dir() else '📄 '}{e.name}" for e in entries
|
|
1414
|
+
)
|
|
1415
|
+
context_parts.append(f"[Contents of @{file_ref}]\n{listing}")
|
|
1416
|
+
clean_input = clean_input.replace(f"@{file_ref}", "").strip()
|
|
1417
|
+
except OSError:
|
|
1418
|
+
pass
|
|
1419
|
+
|
|
1420
|
+
if not context_parts:
|
|
1421
|
+
return user_input
|
|
1422
|
+
|
|
1423
|
+
# Prepend context to the user message
|
|
1424
|
+
context = "\n\n".join(context_parts)
|
|
1425
|
+
return f"{context}\n\n{clean_input}"
|
|
1426
|
+
|
|
1427
|
+
|
|
1428
|
+
def _format_status(runtime: ConversationRuntime, session: Session) -> str:
|
|
1429
|
+
"""Format a full status report."""
|
|
1430
|
+
lines: list[str] = []
|
|
1431
|
+
lines.append("Session Status")
|
|
1432
|
+
lines.append(f" Session ID: {session.session_id}")
|
|
1433
|
+
lines.append(f" Model: {runtime.model}")
|
|
1434
|
+
lines.append(f" Permission mode: {runtime.permission_policy.mode.value}")
|
|
1435
|
+
lines.append(f" Messages: {session.message_count()}")
|
|
1436
|
+
lines.append(f" Turns: {runtime.usage_tracker.turn_count}")
|
|
1437
|
+
|
|
1438
|
+
tokens_est = estimate_session_tokens(session)
|
|
1439
|
+
lines.append(f" Estimated tokens: {tokens_est:,}")
|
|
1440
|
+
|
|
1441
|
+
if runtime.usage_tracker.total.total_tokens() > 0:
|
|
1442
|
+
cost = runtime.usage_tracker.total.estimate_cost_usd()
|
|
1443
|
+
lines.append(f" Total tokens used: {runtime.usage_tracker.total.total_tokens():,}")
|
|
1444
|
+
lines.append(f" Estimated cost: {format_usd(cost.total_cost_usd())}")
|
|
1445
|
+
|
|
1446
|
+
branch = _git_branch()
|
|
1447
|
+
if branch:
|
|
1448
|
+
lines.append(f" Git branch: {branch}")
|
|
1449
|
+
|
|
1450
|
+
git_st = _git_status_short()
|
|
1451
|
+
if git_st:
|
|
1452
|
+
lines.append(f" Git status: {git_st}")
|
|
1453
|
+
|
|
1454
|
+
lines.append(f" Working directory: {Path.cwd()}")
|
|
1455
|
+
|
|
1456
|
+
if session.compaction:
|
|
1457
|
+
lines.append(
|
|
1458
|
+
f" Compactions: {session.compaction.count} "
|
|
1459
|
+
f"(removed {session.compaction.removed_message_count} messages)"
|
|
1460
|
+
)
|
|
1461
|
+
|
|
1462
|
+
if session.fork:
|
|
1463
|
+
lines.append(f" Forked from: {session.fork.parent_session_id}")
|
|
1464
|
+
|
|
1465
|
+
return "\n".join(lines)
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
def _format_config(config: RuntimeConfig) -> str:
|
|
1469
|
+
"""Format the merged configuration for display."""
|
|
1470
|
+
lines: list[str] = []
|
|
1471
|
+
lines.append("Configuration")
|
|
1472
|
+
lines.append("")
|
|
1473
|
+
|
|
1474
|
+
# Sources
|
|
1475
|
+
lines.append(" Sources loaded:")
|
|
1476
|
+
if config.loaded_entries:
|
|
1477
|
+
for entry in config.loaded_entries:
|
|
1478
|
+
lines.append(f" [{entry.source.value}] {entry.path}")
|
|
1479
|
+
else:
|
|
1480
|
+
lines.append(" (none)")
|
|
1481
|
+
|
|
1482
|
+
# Features
|
|
1483
|
+
fc = config.feature_config
|
|
1484
|
+
lines.append("")
|
|
1485
|
+
lines.append(" Features:")
|
|
1486
|
+
if fc.model:
|
|
1487
|
+
lines.append(f" Model: {fc.model}")
|
|
1488
|
+
if fc.permission_mode:
|
|
1489
|
+
lines.append(f" Permission mode: {fc.permission_mode}")
|
|
1490
|
+
if fc.mcp:
|
|
1491
|
+
lines.append(f" MCP servers: {len(fc.mcp)}")
|
|
1492
|
+
if fc.plugins:
|
|
1493
|
+
lines.append(f" Plugins configured: {len(fc.plugins)}")
|
|
1494
|
+
if fc.hooks.pre_tool_use or fc.hooks.post_tool_use:
|
|
1495
|
+
hook_count = len(fc.hooks.pre_tool_use) + len(fc.hooks.post_tool_use)
|
|
1496
|
+
lines.append(f" Hooks: {hook_count}")
|
|
1497
|
+
|
|
1498
|
+
# Merged JSON (compact)
|
|
1499
|
+
lines.append("")
|
|
1500
|
+
lines.append(" Merged config:")
|
|
1501
|
+
if config.merged:
|
|
1502
|
+
formatted = json.dumps(config.merged, indent=4)
|
|
1503
|
+
for line in formatted.splitlines()[:30]:
|
|
1504
|
+
lines.append(f" {line}")
|
|
1505
|
+
if len(formatted.splitlines()) > 30:
|
|
1506
|
+
lines.append(" ... (truncated)")
|
|
1507
|
+
else:
|
|
1508
|
+
lines.append(" {}")
|
|
1509
|
+
|
|
1510
|
+
return "\n".join(lines)
|
|
1511
|
+
|
|
1512
|
+
|
|
1513
|
+
def _handle_memory_command(args: str) -> str:
|
|
1514
|
+
"""Handle /memory command for managing CLAUDE.md."""
|
|
1515
|
+
parts = args.strip().split(maxsplit=1)
|
|
1516
|
+
action = parts[0].lower() if parts else "show"
|
|
1517
|
+
|
|
1518
|
+
claude_md = Path.cwd() / "CLAUDE.md"
|
|
1519
|
+
|
|
1520
|
+
if action == "show":
|
|
1521
|
+
if not claude_md.exists():
|
|
1522
|
+
return "No CLAUDE.md found in current directory."
|
|
1523
|
+
content = claude_md.read_text(encoding="utf-8")
|
|
1524
|
+
if len(content) > 2000:
|
|
1525
|
+
content = content[:2000] + "\n... (truncated)"
|
|
1526
|
+
return f"CLAUDE.md contents:\n\n{content}"
|
|
1527
|
+
|
|
1528
|
+
if action == "add":
|
|
1529
|
+
if len(parts) < 2:
|
|
1530
|
+
return "Usage: /memory add <text to append>"
|
|
1531
|
+
text_to_add = parts[1]
|
|
1532
|
+
with open(claude_md, "a", encoding="utf-8") as f:
|
|
1533
|
+
f.write(f"\n{text_to_add}\n")
|
|
1534
|
+
return "Appended to CLAUDE.md."
|
|
1535
|
+
|
|
1536
|
+
if action == "edit":
|
|
1537
|
+
return "Use /memory show to view, then edit CLAUDE.md directly."
|
|
1538
|
+
|
|
1539
|
+
return "Usage: /memory [show|add <text>|edit]"
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
def _handle_init_command() -> str:
|
|
1543
|
+
"""Handle /init command to create CLAUDE.md."""
|
|
1544
|
+
claude_md = Path.cwd() / "CLAUDE.md"
|
|
1545
|
+
if claude_md.exists():
|
|
1546
|
+
return "CLAUDE.md already exists."
|
|
1547
|
+
claude_md.write_text(
|
|
1548
|
+
"# CLAUDE.md\n\n"
|
|
1549
|
+
"This file provides guidance to Claude Code when working with this codebase.\n\n"
|
|
1550
|
+
"## Project overview\n\n"
|
|
1551
|
+
"<!-- Describe your project here -->\n\n"
|
|
1552
|
+
"## Build & test\n\n"
|
|
1553
|
+
"<!-- Add build and test commands -->\n",
|
|
1554
|
+
encoding="utf-8",
|
|
1555
|
+
)
|
|
1556
|
+
return "Created CLAUDE.md."
|
|
1557
|
+
|
|
1558
|
+
|
|
1559
|
+
def _handle_resume_in_repl(args: str, session: Session, runtime: ConversationRuntime) -> str:
|
|
1560
|
+
"""Handle /resume inside the REPL."""
|
|
1561
|
+
identifier = args.strip() or "latest"
|
|
1562
|
+
path = _resolve_session(identifier)
|
|
1563
|
+
if path is None:
|
|
1564
|
+
return f"No session found for: {identifier}"
|
|
1565
|
+
|
|
1566
|
+
try:
|
|
1567
|
+
loaded = Session.load(path)
|
|
1568
|
+
except Exception as exc:
|
|
1569
|
+
return f"Failed to load session: {exc}"
|
|
1570
|
+
|
|
1571
|
+
# Replace current session state
|
|
1572
|
+
session.session_id = loaded.session_id
|
|
1573
|
+
session.created_at_ms = loaded.created_at_ms
|
|
1574
|
+
session.updated_at_ms = loaded.updated_at_ms
|
|
1575
|
+
session.messages = loaded.messages
|
|
1576
|
+
session.compaction = loaded.compaction
|
|
1577
|
+
session.fork = loaded.fork
|
|
1578
|
+
|
|
1579
|
+
# Rebuild usage tracker from loaded messages
|
|
1580
|
+
runtime.usage_tracker = UsageTracker.from_session(session)
|
|
1581
|
+
|
|
1582
|
+
# Replay full conversation history
|
|
1583
|
+
from axion.cli.tui import render_session_history
|
|
1584
|
+
render_session_history(console, session.messages)
|
|
1585
|
+
|
|
1586
|
+
return (
|
|
1587
|
+
f"Resumed session {session.session_id} "
|
|
1588
|
+
f"({session.message_count()} messages, "
|
|
1589
|
+
f"{runtime.usage_tracker.turn_count} turns)"
|
|
1590
|
+
)
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _handle_plan_command(args: str, runtime: ConversationRuntime, session: Session) -> str:
|
|
1594
|
+
"""Handle /plan [task] | /plan execute | /plan exit."""
|
|
1595
|
+
from axion.runtime.plan_mode import PLAN_MODE_SYSTEM_PROMPT
|
|
1596
|
+
|
|
1597
|
+
subcommand = args.strip().lower().split()[0] if args.strip() else ""
|
|
1598
|
+
task = args.strip()
|
|
1599
|
+
|
|
1600
|
+
# /plan exit — leave plan mode
|
|
1601
|
+
if subcommand in ("exit", "cancel", "stop"):
|
|
1602
|
+
if not runtime.plan_mode_active:
|
|
1603
|
+
return "Not in plan mode."
|
|
1604
|
+
runtime.plan_mode_active = False
|
|
1605
|
+
# Remove the plan mode prompt addition
|
|
1606
|
+
if PLAN_MODE_SYSTEM_PROMPT in runtime.system_prompt:
|
|
1607
|
+
runtime.system_prompt = runtime.system_prompt.replace(PLAN_MODE_SYSTEM_PROMPT, "")
|
|
1608
|
+
return "Exited plan mode. Write tools are now available."
|
|
1609
|
+
|
|
1610
|
+
# /plan execute — approve plan and exit plan mode
|
|
1611
|
+
if subcommand in ("execute", "run", "go", "approve", "yes"):
|
|
1612
|
+
if not runtime.plan_mode_active:
|
|
1613
|
+
return "Not in plan mode. Use /plan <task> to enter plan mode first."
|
|
1614
|
+
runtime.plan_mode_active = False
|
|
1615
|
+
if PLAN_MODE_SYSTEM_PROMPT in runtime.system_prompt:
|
|
1616
|
+
runtime.system_prompt = runtime.system_prompt.replace(PLAN_MODE_SYSTEM_PROMPT, "")
|
|
1617
|
+
return (
|
|
1618
|
+
"Plan approved! Exiting plan mode.\n"
|
|
1619
|
+
"Write tools are now available. Send your next message to start implementing."
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
# /plan status — check if in plan mode
|
|
1623
|
+
if subcommand == "status":
|
|
1624
|
+
if runtime.plan_mode_active:
|
|
1625
|
+
return "Plan mode: ACTIVE (read-only tools only)"
|
|
1626
|
+
return "Plan mode: inactive"
|
|
1627
|
+
|
|
1628
|
+
# /plan (no args) — show help
|
|
1629
|
+
if not task:
|
|
1630
|
+
if runtime.plan_mode_active:
|
|
1631
|
+
return (
|
|
1632
|
+
"Plan mode is ACTIVE.\n"
|
|
1633
|
+
" /plan exit — Leave plan mode\n"
|
|
1634
|
+
" /plan execute — Approve plan and start implementing\n"
|
|
1635
|
+
" /plan status — Check plan mode status"
|
|
1636
|
+
)
|
|
1637
|
+
return (
|
|
1638
|
+
"Usage: /plan <task description>\n"
|
|
1639
|
+
" Enter plan mode where the AI explores and designs before coding.\n"
|
|
1640
|
+
" Only read-only tools (Read, Glob, Grep, WebSearch) are allowed.\n\n"
|
|
1641
|
+
" Example: /plan Add user authentication with JWT tokens\n\n"
|
|
1642
|
+
" Subcommands:\n"
|
|
1643
|
+
" /plan execute — Approve plan and start implementing\n"
|
|
1644
|
+
" /plan exit — Cancel and leave plan mode"
|
|
1645
|
+
)
|
|
1646
|
+
|
|
1647
|
+
# /plan <task> — enter plan mode with a task
|
|
1648
|
+
if runtime.plan_mode_active:
|
|
1649
|
+
return "Already in plan mode. Use /plan exit first, or send your task as a message."
|
|
1650
|
+
|
|
1651
|
+
runtime.plan_mode_active = True
|
|
1652
|
+
# Augment the system prompt with plan mode instructions
|
|
1653
|
+
runtime.system_prompt += PLAN_MODE_SYSTEM_PROMPT
|
|
1654
|
+
|
|
1655
|
+
return (
|
|
1656
|
+
"📋 Plan mode ACTIVE\n"
|
|
1657
|
+
" Only read-only tools allowed (Read, Glob, Grep, WebSearch).\n"
|
|
1658
|
+
" Write/Edit/Bash are blocked until you approve.\n\n"
|
|
1659
|
+
f" Task: {task}\n\n"
|
|
1660
|
+
" Send your next message to start planning, or just say 'go'.\n"
|
|
1661
|
+
" When done: /plan execute to approve, /plan exit to cancel."
|
|
1662
|
+
)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def _handle_diff_command(args: str, session: Session) -> str:
|
|
1666
|
+
"""Handle /diff with syntax-highlighted output using Rich."""
|
|
1667
|
+
from rich.syntax import Syntax
|
|
1668
|
+
|
|
1669
|
+
# Get both staged and unstaged diffs
|
|
1670
|
+
sections: list[str] = []
|
|
1671
|
+
for label, git_args in [
|
|
1672
|
+
("Staged changes", ["git", "diff", "--cached"]),
|
|
1673
|
+
("Unstaged changes", ["git", "diff"]),
|
|
1674
|
+
]:
|
|
1675
|
+
try:
|
|
1676
|
+
result = subprocess.run(
|
|
1677
|
+
git_args, capture_output=True, text=True, timeout=10,
|
|
1678
|
+
)
|
|
1679
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1680
|
+
sections.append(f"### {label}")
|
|
1681
|
+
diff_text = result.stdout.strip()
|
|
1682
|
+
if len(diff_text) > 5000:
|
|
1683
|
+
diff_text = diff_text[:5000] + "\n... (truncated)"
|
|
1684
|
+
# Render with syntax highlighting
|
|
1685
|
+
syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
|
|
1686
|
+
console.print(syntax)
|
|
1687
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
1688
|
+
pass
|
|
1689
|
+
|
|
1690
|
+
if not sections:
|
|
1691
|
+
return "No uncommitted changes."
|
|
1692
|
+
return "" # Already printed via Rich
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def _handle_export_command(args: str, session: Session) -> str:
|
|
1696
|
+
"""Handle /export to save the session transcript."""
|
|
1697
|
+
output_name = args.strip() or f"transcript-{session.session_id}.md"
|
|
1698
|
+
output_path = Path.cwd() / output_name
|
|
1699
|
+
try:
|
|
1700
|
+
_export_transcript(session, output_path)
|
|
1701
|
+
return f"Exported transcript to: {output_path}"
|
|
1702
|
+
except Exception as exc:
|
|
1703
|
+
return f"Export failed: {exc}"
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def _handle_session_command(args: str, session: Session) -> str:
|
|
1707
|
+
"""Handle /session [list|show|fork|save] commands."""
|
|
1708
|
+
parts = args.strip().split(maxsplit=1)
|
|
1709
|
+
action = parts[0].lower() if parts else "show"
|
|
1710
|
+
|
|
1711
|
+
if action == "show":
|
|
1712
|
+
return (
|
|
1713
|
+
f"Session ID: {session.session_id}\n"
|
|
1714
|
+
f"Created: {datetime.fromtimestamp(session.created_at_ms / 1000).isoformat()}\n"
|
|
1715
|
+
f"Updated: {datetime.fromtimestamp(session.updated_at_ms / 1000).isoformat()}\n"
|
|
1716
|
+
f"Messages: {session.message_count()}\n"
|
|
1717
|
+
f"Compactions: {session.compaction.count if session.compaction else 0}"
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1720
|
+
if action == "list":
|
|
1721
|
+
files = _list_sessions()
|
|
1722
|
+
if not files:
|
|
1723
|
+
return "No saved sessions."
|
|
1724
|
+
lines = ["Saved sessions:", ""]
|
|
1725
|
+
for f in files:
|
|
1726
|
+
mod_time = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
|
1727
|
+
lines.append(f" {f.stem} ({mod_time})")
|
|
1728
|
+
return "\n".join(lines)
|
|
1729
|
+
|
|
1730
|
+
if action == "fork":
|
|
1731
|
+
from axion.runtime.session import SessionFork
|
|
1732
|
+
branch_name = parts[1].strip() if len(parts) > 1 else None
|
|
1733
|
+
old_id = session.session_id
|
|
1734
|
+
# Create new session ID but keep messages
|
|
1735
|
+
import uuid
|
|
1736
|
+
session.session_id = uuid.uuid4().hex[:16]
|
|
1737
|
+
session.fork = SessionFork(
|
|
1738
|
+
parent_session_id=old_id,
|
|
1739
|
+
branch_name=branch_name,
|
|
1740
|
+
)
|
|
1741
|
+
return f"Forked session {old_id} -> {session.session_id}"
|
|
1742
|
+
|
|
1743
|
+
if action == "save":
|
|
1744
|
+
path = _session_path_for_id(session.session_id)
|
|
1745
|
+
session.save(path)
|
|
1746
|
+
return f"Session saved to: {path}"
|
|
1747
|
+
|
|
1748
|
+
if action in ("switch", "sw"):
|
|
1749
|
+
target = parts[1].strip() if len(parts) > 1 else ""
|
|
1750
|
+
if not target:
|
|
1751
|
+
return "Usage: /session switch <session_id|latest>"
|
|
1752
|
+
path = _resolve_session(target)
|
|
1753
|
+
if path is None:
|
|
1754
|
+
return f"Session not found: {target}"
|
|
1755
|
+
try:
|
|
1756
|
+
loaded = Session.load(path)
|
|
1757
|
+
# Replace current session state
|
|
1758
|
+
session.session_id = loaded.session_id
|
|
1759
|
+
session.messages = loaded.messages
|
|
1760
|
+
session.created_at_ms = loaded.created_at_ms
|
|
1761
|
+
session.updated_at_ms = loaded.updated_at_ms
|
|
1762
|
+
session.compaction = loaded.compaction
|
|
1763
|
+
session.fork = loaded.fork
|
|
1764
|
+
# Update persistence path
|
|
1765
|
+
session.with_persistence_path(_session_path_for_id(loaded.session_id))
|
|
1766
|
+
return (
|
|
1767
|
+
f"Switched to session {loaded.session_id}\n"
|
|
1768
|
+
f" Messages: {loaded.message_count()}\n"
|
|
1769
|
+
f" Created: {datetime.fromtimestamp(loaded.created_at_ms / 1000).strftime('%Y-%m-%d %H:%M')}"
|
|
1770
|
+
)
|
|
1771
|
+
except Exception as exc:
|
|
1772
|
+
return f"Failed to switch session: {exc}"
|
|
1773
|
+
|
|
1774
|
+
if action in ("delete", "rm"):
|
|
1775
|
+
target = parts[1].strip() if len(parts) > 1 else ""
|
|
1776
|
+
if not target:
|
|
1777
|
+
return "Usage: /session delete <session_id>"
|
|
1778
|
+
if target == session.session_id:
|
|
1779
|
+
return "Cannot delete the current active session."
|
|
1780
|
+
path = _resolve_session(target)
|
|
1781
|
+
if path is None:
|
|
1782
|
+
return f"Session not found: {target}"
|
|
1783
|
+
try:
|
|
1784
|
+
path.unlink()
|
|
1785
|
+
return f"Deleted session: {path.stem}"
|
|
1786
|
+
except Exception as exc:
|
|
1787
|
+
return f"Failed to delete session: {exc}"
|
|
1788
|
+
|
|
1789
|
+
if action == "new":
|
|
1790
|
+
# Save current session first
|
|
1791
|
+
try:
|
|
1792
|
+
session.save()
|
|
1793
|
+
except Exception:
|
|
1794
|
+
pass
|
|
1795
|
+
old_id = session.session_id
|
|
1796
|
+
# Reset to a fresh session
|
|
1797
|
+
import uuid
|
|
1798
|
+
session.session_id = uuid.uuid4().hex[:16]
|
|
1799
|
+
session.messages.clear()
|
|
1800
|
+
session.compaction = None
|
|
1801
|
+
session.fork = None
|
|
1802
|
+
session.created_at_ms = int(time.time() * 1000)
|
|
1803
|
+
session.updated_at_ms = session.created_at_ms
|
|
1804
|
+
session.with_persistence_path(_session_path_for_id(session.session_id))
|
|
1805
|
+
return f"New session {session.session_id} (previous: {old_id})"
|
|
1806
|
+
|
|
1807
|
+
return "Usage: /session [show|list|fork|save|switch|delete|new]"
|
|
1808
|
+
|
|
1809
|
+
|
|
1810
|
+
def _run_doctor_checks() -> str:
|
|
1811
|
+
"""Run health checks and return results."""
|
|
1812
|
+
lines: list[str] = []
|
|
1813
|
+
lines.append("Axion Code Doctor")
|
|
1814
|
+
lines.append("")
|
|
1815
|
+
|
|
1816
|
+
# Python version
|
|
1817
|
+
py_version = sys.version.split()[0]
|
|
1818
|
+
py_ok = sys.version_info >= (3, 11)
|
|
1819
|
+
lines.append(f" Python: {py_version} {'OK' if py_ok else 'NEEDS 3.11+'}")
|
|
1820
|
+
|
|
1821
|
+
# API key
|
|
1822
|
+
has_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
1823
|
+
lines.append(f" ANTHROPIC_API_KEY: {'SET' if has_key else 'NOT SET'}")
|
|
1824
|
+
|
|
1825
|
+
# OAuth credentials
|
|
1826
|
+
oauth_creds = load_oauth_credentials("anthropic")
|
|
1827
|
+
if oauth_creds:
|
|
1828
|
+
expired_str = " (expired)" if oauth_creds.is_expired() else " (valid)"
|
|
1829
|
+
lines.append(f" OAuth credentials: FOUND{expired_str}")
|
|
1830
|
+
else:
|
|
1831
|
+
lines.append(" OAuth credentials: NOT FOUND")
|
|
1832
|
+
|
|
1833
|
+
# Dependencies
|
|
1834
|
+
deps = ["httpx", "rich", "click", "prompt_toolkit"]
|
|
1835
|
+
for dep in deps:
|
|
1836
|
+
try:
|
|
1837
|
+
__import__(dep)
|
|
1838
|
+
lines.append(f" {dep}: OK")
|
|
1839
|
+
except ImportError:
|
|
1840
|
+
lines.append(f" {dep}: MISSING")
|
|
1841
|
+
|
|
1842
|
+
# Git
|
|
1843
|
+
try:
|
|
1844
|
+
result = subprocess.run(
|
|
1845
|
+
["git", "--version"], capture_output=True, text=True, timeout=5,
|
|
1846
|
+
)
|
|
1847
|
+
if result.returncode == 0:
|
|
1848
|
+
lines.append(f" git: {result.stdout.strip()}")
|
|
1849
|
+
else:
|
|
1850
|
+
lines.append(" git: NOT FOUND")
|
|
1851
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
1852
|
+
lines.append(" git: NOT FOUND")
|
|
1853
|
+
|
|
1854
|
+
# Sandbox
|
|
1855
|
+
sandbox = detect_sandbox()
|
|
1856
|
+
lines.append(f" Sandbox: {'available' if sandbox.available else 'not available'} ({sandbox.details})")
|
|
1857
|
+
|
|
1858
|
+
# Config files
|
|
1859
|
+
loader = ConfigLoader()
|
|
1860
|
+
config = loader.load()
|
|
1861
|
+
lines.append(f" Config sources loaded: {len(config.loaded_entries)}")
|
|
1862
|
+
|
|
1863
|
+
# Session directory
|
|
1864
|
+
sd = Path.cwd() / SESSION_DIR
|
|
1865
|
+
sessions_count = len(list(sd.glob("*.jsonl"))) if sd.exists() else 0
|
|
1866
|
+
lines.append(f" Sessions directory: {sd} ({sessions_count} sessions)")
|
|
1867
|
+
|
|
1868
|
+
lines.append("")
|
|
1869
|
+
return "\n".join(lines)
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
# ---------------------------------------------------------------------------
|
|
1873
|
+
# REPL loop
|
|
1874
|
+
# ---------------------------------------------------------------------------
|
|
1875
|
+
|
|
1876
|
+
async def run_repl(
|
|
1877
|
+
model: str,
|
|
1878
|
+
permission_mode: str,
|
|
1879
|
+
resume: str | None = None,
|
|
1880
|
+
output_format: str = "text",
|
|
1881
|
+
budget: float | None = None,
|
|
1882
|
+
) -> int:
|
|
1883
|
+
"""Run the interactive REPL loop."""
|
|
1884
|
+
from axion.cli.input import InputSession
|
|
1885
|
+
|
|
1886
|
+
config = _load_config()
|
|
1887
|
+
plugin_manager = _create_plugin_manager()
|
|
1888
|
+
|
|
1889
|
+
# Create or resume session
|
|
1890
|
+
if resume:
|
|
1891
|
+
path = _resolve_session(resume)
|
|
1892
|
+
if path is None:
|
|
1893
|
+
console.print(f"[red]Session not found: {resume}[/red]")
|
|
1894
|
+
return 1
|
|
1895
|
+
try:
|
|
1896
|
+
session = Session.load(path)
|
|
1897
|
+
console.print(f"[dim]Resumed session {session.session_id} ({session.message_count()} messages)[/dim]")
|
|
1898
|
+
|
|
1899
|
+
# Replay full conversation history so it feels like continuing
|
|
1900
|
+
if session.messages and output_format != "json":
|
|
1901
|
+
from axion.cli.tui import render_session_history
|
|
1902
|
+
render_session_history(console, session.messages)
|
|
1903
|
+
except Exception as exc:
|
|
1904
|
+
console.print(f"[red]Failed to load session: {exc}[/red]")
|
|
1905
|
+
return 1
|
|
1906
|
+
else:
|
|
1907
|
+
session = Session()
|
|
1908
|
+
|
|
1909
|
+
# Set up persistence
|
|
1910
|
+
session_path = _session_path_for_id(session.session_id)
|
|
1911
|
+
session.with_persistence_path(session_path)
|
|
1912
|
+
|
|
1913
|
+
# Build runtime with markdown streaming and real-time tool display
|
|
1914
|
+
text_buffer: list[str] = []
|
|
1915
|
+
repl_md_stream = MarkdownStreamState()
|
|
1916
|
+
|
|
1917
|
+
def on_text_delta(text: str) -> None:
|
|
1918
|
+
text_buffer.append(text)
|
|
1919
|
+
if output_format == "json":
|
|
1920
|
+
return
|
|
1921
|
+
# Buffer text until we hit a safe boundary (blank line / fence end),
|
|
1922
|
+
# then render that chunk as Markdown so headers, bold, lists, code
|
|
1923
|
+
# blocks, and tables all show with proper formatting.
|
|
1924
|
+
rendered = repl_md_stream.push(renderer, text)
|
|
1925
|
+
if rendered:
|
|
1926
|
+
console.print(Markdown(rendered), end="")
|
|
1927
|
+
|
|
1928
|
+
def on_tool_use_cb(tool_name: str, tool_input: str) -> None:
|
|
1929
|
+
"""Show tool invocation in real-time as it happens."""
|
|
1930
|
+
if output_format == "json":
|
|
1931
|
+
return
|
|
1932
|
+
# Flush any pending markdown before showing tool
|
|
1933
|
+
remaining = repl_md_stream.flush(renderer)
|
|
1934
|
+
if remaining:
|
|
1935
|
+
console.print(Markdown(remaining))
|
|
1936
|
+
# Parse input for inline display
|
|
1937
|
+
try:
|
|
1938
|
+
params = json.loads(tool_input) if tool_input else {}
|
|
1939
|
+
except (json.JSONDecodeError, TypeError):
|
|
1940
|
+
params = {"input": tool_input[:200]} if tool_input else {}
|
|
1941
|
+
render_tool_call_inline(console, tool_name, params)
|
|
1942
|
+
|
|
1943
|
+
def on_tool_result_cb(tool_name: str, output: str, is_error: bool) -> None:
|
|
1944
|
+
"""Show tool result in real-time as it completes."""
|
|
1945
|
+
if output_format == "json":
|
|
1946
|
+
return
|
|
1947
|
+
render_tool_result_inline(console, tool_name, output, is_error)
|
|
1948
|
+
|
|
1949
|
+
thinking_started = [False] # mutable flag for closure
|
|
1950
|
+
|
|
1951
|
+
def on_thinking_cb(thinking_text: str) -> None:
|
|
1952
|
+
"""Show collapsed thinking indicator."""
|
|
1953
|
+
if output_format == "json":
|
|
1954
|
+
return
|
|
1955
|
+
if not thinking_started[0]:
|
|
1956
|
+
thinking_started[0] = True
|
|
1957
|
+
console.print("[dim italic]💭 Thinking...[/dim italic]")
|
|
1958
|
+
# Don't show the actual thinking text — just the indicator
|
|
1959
|
+
|
|
1960
|
+
runtime, provider = _build_runtime(
|
|
1961
|
+
model=model,
|
|
1962
|
+
permission_mode=permission_mode,
|
|
1963
|
+
session=session,
|
|
1964
|
+
config=config,
|
|
1965
|
+
on_text_delta=on_text_delta,
|
|
1966
|
+
on_tool_use=on_tool_use_cb,
|
|
1967
|
+
on_tool_result=on_tool_result_cb,
|
|
1968
|
+
)
|
|
1969
|
+
runtime.on_thinking = on_thinking_cb
|
|
1970
|
+
if budget is not None:
|
|
1971
|
+
runtime.cost_budget_usd = budget
|
|
1972
|
+
|
|
1973
|
+
# Restore usage tracker from resumed session
|
|
1974
|
+
if resume:
|
|
1975
|
+
runtime.usage_tracker = UsageTracker.from_session(session)
|
|
1976
|
+
|
|
1977
|
+
# Welcome screen with TUI (skip on resume — history is shown instead)
|
|
1978
|
+
if output_format != "json" and not resume:
|
|
1979
|
+
perm_display = runtime.permission_policy.mode.value
|
|
1980
|
+
branch = _git_branch()
|
|
1981
|
+
auth_mode_label = _detect_auth_mode_label(runtime.model)
|
|
1982
|
+
|
|
1983
|
+
render_welcome_screen(
|
|
1984
|
+
console,
|
|
1985
|
+
version=__version__,
|
|
1986
|
+
model=runtime.model,
|
|
1987
|
+
session_id=session.session_id,
|
|
1988
|
+
permission_mode=perm_display,
|
|
1989
|
+
git_branch=branch,
|
|
1990
|
+
resumed=False,
|
|
1991
|
+
message_count=session.message_count(),
|
|
1992
|
+
cwd=str(Path.cwd()),
|
|
1993
|
+
auth_mode=auth_mode_label,
|
|
1994
|
+
)
|
|
1995
|
+
|
|
1996
|
+
# Friendly hint when a subscription is saved but the active model
|
|
1997
|
+
# can't use it (e.g. ChatGPT subscription saved but on gpt-4o).
|
|
1998
|
+
_maybe_warn_subscription_unused(runtime.model)
|
|
1999
|
+
|
|
2000
|
+
# Input session with textarea styling
|
|
2001
|
+
input_session = InputSession(history_path=InputSession.default_history_path())
|
|
2002
|
+
|
|
2003
|
+
_turn_interrupted = False
|
|
2004
|
+
|
|
2005
|
+
try:
|
|
2006
|
+
while True:
|
|
2007
|
+
# Read input with textarea-styled box
|
|
2008
|
+
try:
|
|
2009
|
+
prompt_label = "axion[plan]" if runtime.plan_mode_active else "axion"
|
|
2010
|
+
user_input = await input_session.prompt(prompt_label)
|
|
2011
|
+
if user_input is None:
|
|
2012
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
2013
|
+
break
|
|
2014
|
+
except (EOFError, KeyboardInterrupt):
|
|
2015
|
+
console.print("\n[dim]Goodbye![/dim]")
|
|
2016
|
+
break
|
|
2017
|
+
|
|
2018
|
+
user_input = user_input.strip()
|
|
2019
|
+
if not user_input:
|
|
2020
|
+
continue
|
|
2021
|
+
|
|
2022
|
+
# Catch common mistake: typing a command without the /
|
|
2023
|
+
_command_words = {
|
|
2024
|
+
"help", "quit", "exit", "clear", "cost", "status", "model",
|
|
2025
|
+
"compact", "config", "diff", "export", "doctor", "version",
|
|
2026
|
+
"resume", "login", "logout", "session", "plugins", "skills",
|
|
2027
|
+
"agents", "mcp", "memory", "models", "permissions", "sandbox",
|
|
2028
|
+
}
|
|
2029
|
+
first_word = user_input.split()[0].lower()
|
|
2030
|
+
if first_word in _command_words:
|
|
2031
|
+
console.print(
|
|
2032
|
+
f"[yellow]Did you mean [bold]/{user_input}[/bold]? "
|
|
2033
|
+
f"Commands start with /[/yellow]"
|
|
2034
|
+
)
|
|
2035
|
+
continue
|
|
2036
|
+
|
|
2037
|
+
# Handle slash commands
|
|
2038
|
+
if user_input.startswith("/"):
|
|
2039
|
+
response = await _handle_slash_command(
|
|
2040
|
+
user_input, runtime, session, plugin_manager, config
|
|
2041
|
+
)
|
|
2042
|
+
if response is None:
|
|
2043
|
+
# Exit signal
|
|
2044
|
+
console.print("[dim]Goodbye![/dim]")
|
|
2045
|
+
break
|
|
2046
|
+
|
|
2047
|
+
# Check if the command wants to trigger an AI turn
|
|
2048
|
+
if isinstance(response, str) and response.startswith("__RUN_TURN__:"):
|
|
2049
|
+
# Extract the prompt and send it as a regular turn
|
|
2050
|
+
user_input = response[len("__RUN_TURN__:"):]
|
|
2051
|
+
# Fall through to the "Send to model" section below
|
|
2052
|
+
else:
|
|
2053
|
+
if response:
|
|
2054
|
+
console.print(f"[dim]{response}[/dim]")
|
|
2055
|
+
# Persist session after commands that mutate state
|
|
2056
|
+
try:
|
|
2057
|
+
session.save()
|
|
2058
|
+
except Exception:
|
|
2059
|
+
pass
|
|
2060
|
+
continue
|
|
2061
|
+
|
|
2062
|
+
# Send to model
|
|
2063
|
+
text_buffer.clear()
|
|
2064
|
+
_turn_interrupted = False
|
|
2065
|
+
thinking_started[0] = False
|
|
2066
|
+
|
|
2067
|
+
# Spinner to show while waiting for first response
|
|
2068
|
+
from axion.cli.render import Spinner as AxionSpinner
|
|
2069
|
+
spinner = AxionSpinner()
|
|
2070
|
+
spinner_active = True
|
|
2071
|
+
|
|
2072
|
+
_original_text_cb = runtime.on_text_delta
|
|
2073
|
+
_original_tool_cb = runtime.on_tool_use
|
|
2074
|
+
|
|
2075
|
+
def _stop_spinner() -> None:
|
|
2076
|
+
nonlocal spinner_active
|
|
2077
|
+
if spinner_active:
|
|
2078
|
+
spinner.stop()
|
|
2079
|
+
spinner_active = False
|
|
2080
|
+
|
|
2081
|
+
def _text_with_spinner(text: str) -> None:
|
|
2082
|
+
_stop_spinner()
|
|
2083
|
+
if _original_text_cb:
|
|
2084
|
+
_original_text_cb(text)
|
|
2085
|
+
|
|
2086
|
+
def _tool_with_spinner(name: str, inp: str) -> None:
|
|
2087
|
+
_stop_spinner()
|
|
2088
|
+
if _original_tool_cb:
|
|
2089
|
+
_original_tool_cb(name, inp)
|
|
2090
|
+
|
|
2091
|
+
runtime.on_text_delta = _text_with_spinner
|
|
2092
|
+
runtime.on_tool_use = _tool_with_spinner
|
|
2093
|
+
# Wire spinner stop into permission prompter so it clears before [y/N] shows
|
|
2094
|
+
if runtime.permission_prompter and hasattr(runtime.permission_prompter, '_stop_spinner_fn'):
|
|
2095
|
+
runtime.permission_prompter._stop_spinner_fn = _stop_spinner
|
|
2096
|
+
|
|
2097
|
+
# Expand @file references before sending to the model
|
|
2098
|
+
user_input = _inject_file_context(user_input)
|
|
2099
|
+
|
|
2100
|
+
# Inline /image — detect /image anywhere in input (not just as first word)
|
|
2101
|
+
# e.g. "this looks generic /image" grabs clipboard + sends text
|
|
2102
|
+
user_input = _handle_inline_image(user_input, runtime)
|
|
2103
|
+
|
|
2104
|
+
# Auto-detect image file paths in user input
|
|
2105
|
+
user_input, auto_images = _extract_image_paths(user_input)
|
|
2106
|
+
if auto_images:
|
|
2107
|
+
if not hasattr(runtime, "_pending_images"):
|
|
2108
|
+
runtime._pending_images = [] # type: ignore[attr-defined]
|
|
2109
|
+
runtime._pending_images.extend(auto_images) # type: ignore[attr-defined]
|
|
2110
|
+
|
|
2111
|
+
try:
|
|
2112
|
+
if output_format != "json":
|
|
2113
|
+
console.print() # Blank line before response
|
|
2114
|
+
spinner.start("Thinking...")
|
|
2115
|
+
|
|
2116
|
+
# Collect any pending images (from /image command or auto-detect)
|
|
2117
|
+
pending_imgs: list[tuple[str, str]] | None = None
|
|
2118
|
+
if hasattr(runtime, "_pending_images") and runtime._pending_images: # type: ignore[attr-defined]
|
|
2119
|
+
pending_imgs = list(runtime._pending_images) # type: ignore[attr-defined]
|
|
2120
|
+
runtime._pending_images.clear() # type: ignore[attr-defined]
|
|
2121
|
+
|
|
2122
|
+
summary = await runtime.run_turn(user_input, images=pending_imgs)
|
|
2123
|
+
_stop_spinner()
|
|
2124
|
+
|
|
2125
|
+
if output_format == "json":
|
|
2126
|
+
json_out = _build_json_output(summary, runtime.model)
|
|
2127
|
+
click.echo(json.dumps(json_out))
|
|
2128
|
+
else:
|
|
2129
|
+
# Flush any remaining buffered markdown so the final
|
|
2130
|
+
# paragraph / partial sentence renders before the prompt.
|
|
2131
|
+
remaining = repl_md_stream.flush(renderer)
|
|
2132
|
+
if remaining:
|
|
2133
|
+
console.print(Markdown(remaining))
|
|
2134
|
+
elif text_buffer:
|
|
2135
|
+
console.print() # Newline after streamed text
|
|
2136
|
+
|
|
2137
|
+
# Cost line with TUI styling
|
|
2138
|
+
if summary.usage.total_tokens() > 0:
|
|
2139
|
+
from axion.runtime.usage import pricing_for_model
|
|
2140
|
+
model_pricing = pricing_for_model(runtime.model)
|
|
2141
|
+
if model_pricing:
|
|
2142
|
+
cost = summary.usage.estimate_cost_usd_with_pricing(model_pricing)
|
|
2143
|
+
else:
|
|
2144
|
+
cost = summary.usage.estimate_cost_usd()
|
|
2145
|
+
turn_cost = cost.total_cost_usd()
|
|
2146
|
+
turn_tokens = summary.usage.total_tokens()
|
|
2147
|
+
turn_num = runtime.usage_tracker.turn_count
|
|
2148
|
+
# Detect auth mode for the bottom toolbar
|
|
2149
|
+
_auth_mode = _detect_auth_mode_label(runtime.model)
|
|
2150
|
+
|
|
2151
|
+
# Update the bottom toolbar (live stats stay visible)
|
|
2152
|
+
# — no inline cost line; bottom toolbar already shows it
|
|
2153
|
+
input_session.update_status(
|
|
2154
|
+
model=runtime.model,
|
|
2155
|
+
tokens=turn_tokens,
|
|
2156
|
+
cost=turn_cost,
|
|
2157
|
+
turn=turn_num,
|
|
2158
|
+
auth_mode=_auth_mode,
|
|
2159
|
+
)
|
|
2160
|
+
|
|
2161
|
+
except KeyboardInterrupt:
|
|
2162
|
+
_stop_spinner()
|
|
2163
|
+
_turn_interrupted = True
|
|
2164
|
+
console.print("\n[yellow]Interrupted[/yellow]")
|
|
2165
|
+
repl_md_stream._pending = ""
|
|
2166
|
+
repl_md_stream._in_code_fence = False
|
|
2167
|
+
except Exception as exc:
|
|
2168
|
+
_stop_spinner()
|
|
2169
|
+
repl_md_stream._pending = ""
|
|
2170
|
+
repl_md_stream._in_code_fence = False
|
|
2171
|
+
error_msg = str(exc)
|
|
2172
|
+
if output_format == "json":
|
|
2173
|
+
click.echo(json.dumps({"error": error_msg}))
|
|
2174
|
+
elif "context window" in error_msg.lower() or "too many tokens" in error_msg.lower():
|
|
2175
|
+
renderer.render_context_window_error(
|
|
2176
|
+
model=runtime.model,
|
|
2177
|
+
estimated_tokens=estimate_session_tokens(session),
|
|
2178
|
+
context_window=200_000,
|
|
2179
|
+
session_id=session.session_id,
|
|
2180
|
+
)
|
|
2181
|
+
console.print("[dim]Try /compact to reduce history or /clear to start fresh.[/dim]")
|
|
2182
|
+
elif "api key" in error_msg.lower() or "credentials" in error_msg.lower():
|
|
2183
|
+
console.print(f"[red]Authentication error: {error_msg}[/red]")
|
|
2184
|
+
console.print("[dim]Check your API key or run axion login[/dim]")
|
|
2185
|
+
elif "rate limit" in error_msg.lower() or "429" in error_msg:
|
|
2186
|
+
# Detect if user is on Claude subscription
|
|
2187
|
+
using_subscription = False
|
|
2188
|
+
try:
|
|
2189
|
+
from axion.runtime.claude_subscription import has_subscription_credentials
|
|
2190
|
+
using_subscription = (
|
|
2191
|
+
has_subscription_credentials()
|
|
2192
|
+
and runtime.model.startswith("claude")
|
|
2193
|
+
and os.environ.get("AXION_AUTH_MODE", "").lower() != "api"
|
|
2194
|
+
)
|
|
2195
|
+
except Exception:
|
|
2196
|
+
pass
|
|
2197
|
+
|
|
2198
|
+
# Pull the retry hint our anthropic client embeds into the message
|
|
2199
|
+
# (format: "... — retry at HH:MM (in N min)")
|
|
2200
|
+
import re as _re
|
|
2201
|
+
retry_hint = ""
|
|
2202
|
+
m = _re.search(r"retry at \d{2}:\d{2} \([^)]+\)", error_msg)
|
|
2203
|
+
if m:
|
|
2204
|
+
retry_hint = m.group(0)
|
|
2205
|
+
|
|
2206
|
+
if retry_hint:
|
|
2207
|
+
console.print(
|
|
2208
|
+
f"\n[yellow]Rate limit hit[/yellow] — [bold]{retry_hint}[/bold]"
|
|
2209
|
+
)
|
|
2210
|
+
else:
|
|
2211
|
+
console.print("\n[yellow]Rate limit hit (HTTP 429)[/yellow]")
|
|
2212
|
+
|
|
2213
|
+
if using_subscription:
|
|
2214
|
+
console.print(
|
|
2215
|
+
"[dim]Your Claude Pro/Max plan limits messages per 5-hour window:[/dim]"
|
|
2216
|
+
)
|
|
2217
|
+
console.print("[dim] • Pro: ~45 messages / 5h[/dim]")
|
|
2218
|
+
console.print("[dim] • Max: ~225-900 messages / 5h (depending on tier)[/dim]")
|
|
2219
|
+
console.print()
|
|
2220
|
+
console.print("[dim]Options while you wait:[/dim]")
|
|
2221
|
+
console.print("[dim] • Switch to API key billing: [bold]/auth-mode api[/bold][/dim]")
|
|
2222
|
+
console.print("[dim] • Use a different provider: [bold]/model gpt-5[/bold] or [bold]/model grok-2[/bold][/dim]")
|
|
2223
|
+
else:
|
|
2224
|
+
console.print("[dim]The API is rate-limiting your requests. Options:[/dim]")
|
|
2225
|
+
if not retry_hint:
|
|
2226
|
+
console.print("[dim] • Wait a moment and try again[/dim]")
|
|
2227
|
+
console.print("[dim] • Switch model: [bold]/model gpt-5[/bold] or [bold]/model grok-2[/bold][/dim]")
|
|
2228
|
+
elif "only supported in" in error_msg.lower() or "v1/responses" in error_msg.lower():
|
|
2229
|
+
console.print(f"[yellow]Model not compatible: {runtime.model}[/yellow]")
|
|
2230
|
+
console.print("[dim]This model requires a different API. Try /model gpt-5 or /model gpt-4.1 instead.[/dim]")
|
|
2231
|
+
elif any(
|
|
2232
|
+
kw in error_msg.lower()
|
|
2233
|
+
for kw in ("timeout", "connect", "readerror", "read error", "network", "httpx")
|
|
2234
|
+
) or any(
|
|
2235
|
+
isinstance(exc.__cause__, t)
|
|
2236
|
+
for t in (ConnectionError, OSError, TimeoutError)
|
|
2237
|
+
if exc.__cause__
|
|
2238
|
+
):
|
|
2239
|
+
console.print(f"[yellow]Connection error: {error_msg or 'Network request failed'}[/yellow]")
|
|
2240
|
+
console.print("[dim]Check your internet connection and try again.[/dim]")
|
|
2241
|
+
else:
|
|
2242
|
+
console.print(f"\n[red]Error: {error_msg}[/red]")
|
|
2243
|
+
logger.debug("Error during turn", exc_info=True)
|
|
2244
|
+
|
|
2245
|
+
finally:
|
|
2246
|
+
# Restore original callbacks
|
|
2247
|
+
runtime.on_text_delta = _original_text_cb
|
|
2248
|
+
runtime.on_tool_use = _original_tool_cb
|
|
2249
|
+
|
|
2250
|
+
# Persist session after each turn
|
|
2251
|
+
try:
|
|
2252
|
+
session.save()
|
|
2253
|
+
except Exception:
|
|
2254
|
+
logger.debug("Failed to persist session", exc_info=True)
|
|
2255
|
+
|
|
2256
|
+
finally:
|
|
2257
|
+
await provider.close()
|
|
2258
|
+
|
|
2259
|
+
return 0
|
|
2260
|
+
|
|
2261
|
+
|
|
2262
|
+
# ---------------------------------------------------------------------------
|
|
2263
|
+
# OAuth login/logout
|
|
2264
|
+
# ---------------------------------------------------------------------------
|
|
2265
|
+
|
|
2266
|
+
async def _run_subscription_login() -> int:
|
|
2267
|
+
"""Paste-style OAuth login for Claude Pro/Max subscription users."""
|
|
2268
|
+
from axion.runtime.claude_subscription import (
|
|
2269
|
+
begin_subscription_login,
|
|
2270
|
+
complete_subscription_login,
|
|
2271
|
+
has_subscription_credentials,
|
|
2272
|
+
logout_subscription,
|
|
2273
|
+
)
|
|
2274
|
+
|
|
2275
|
+
console.print("[bold]Axion Code — Claude Subscription Login[/bold]\n")
|
|
2276
|
+
|
|
2277
|
+
if has_subscription_credentials():
|
|
2278
|
+
console.print("[green]Already logged in with Claude Pro/Max subscription.[/green]")
|
|
2279
|
+
console.print("[dim]Run 'axion logout' to clear and re-authenticate.[/dim]")
|
|
2280
|
+
return 0
|
|
2281
|
+
|
|
2282
|
+
console.print("[dim]Requests will be billed against your Pro/Max plan, not the API.[/dim]\n")
|
|
2283
|
+
|
|
2284
|
+
auth_url, pkce, state = await begin_subscription_login()
|
|
2285
|
+
|
|
2286
|
+
console.print("[bold]Step 1.[/bold] Open this URL in your browser to log in:")
|
|
2287
|
+
console.print()
|
|
2288
|
+
console.print(f" [link={auth_url}][cyan]{auth_url}[/cyan][/link]")
|
|
2289
|
+
console.print()
|
|
2290
|
+
console.print("[dim]Tip: it should have opened automatically.[/dim]")
|
|
2291
|
+
console.print()
|
|
2292
|
+
console.print("[bold]Step 2.[/bold] After authorizing, you'll see a page titled [bold]\"Authentication Code\"[/bold].")
|
|
2293
|
+
console.print(" Click [cyan]Copy Code[/cyan] (or copy the long string in the box) and paste it below.")
|
|
2294
|
+
console.print(" [dim]Don't paste the URL from your browser bar — paste the code from the page.[/dim]")
|
|
2295
|
+
console.print()
|
|
2296
|
+
|
|
2297
|
+
try:
|
|
2298
|
+
pasted = console.input("[cyan]Paste code: [/cyan]").strip()
|
|
2299
|
+
except (EOFError, KeyboardInterrupt):
|
|
2300
|
+
console.print("\n[dim]Cancelled.[/dim]")
|
|
2301
|
+
return 1
|
|
2302
|
+
|
|
2303
|
+
if not pasted:
|
|
2304
|
+
console.print("[yellow]No code entered.[/yellow]")
|
|
2305
|
+
return 1
|
|
2306
|
+
|
|
2307
|
+
console.print("\n[dim]Exchanging code for tokens...[/dim]")
|
|
2308
|
+
result = await complete_subscription_login(pasted, pkce, state)
|
|
2309
|
+
|
|
2310
|
+
if result.success:
|
|
2311
|
+
console.print("\n[bold green]Logged in![/bold green]")
|
|
2312
|
+
console.print("[dim]Tokens saved to ~/.axion/credentials/anthropic-oauth.json[/dim]")
|
|
2313
|
+
console.print("\nRun [cyan]axion[/cyan] to start. Requests now use your subscription.")
|
|
2314
|
+
return 0
|
|
2315
|
+
else:
|
|
2316
|
+
logout_subscription()
|
|
2317
|
+
console.print(f"\n[red]Login failed:[/red] {result.error}")
|
|
2318
|
+
console.print("[dim]Try again, or use API key login instead with: axion login[/dim]")
|
|
2319
|
+
return 1
|
|
2320
|
+
|
|
2321
|
+
|
|
2322
|
+
async def _run_openai_subscription_login() -> int:
|
|
2323
|
+
"""Local-callback OAuth login for ChatGPT subscription (Codex models)."""
|
|
2324
|
+
from axion.runtime.openai_subscription import (
|
|
2325
|
+
get_openai_subscription_plan,
|
|
2326
|
+
has_openai_subscription_credentials,
|
|
2327
|
+
login_with_openai_subscription,
|
|
2328
|
+
logout_openai_subscription,
|
|
2329
|
+
)
|
|
2330
|
+
|
|
2331
|
+
console.print("[bold]Axion Code — ChatGPT Subscription Login[/bold]\n")
|
|
2332
|
+
|
|
2333
|
+
if has_openai_subscription_credentials():
|
|
2334
|
+
plan = get_openai_subscription_plan() or "ChatGPT"
|
|
2335
|
+
console.print(f"[green]Already logged in with {plan} subscription.[/green]")
|
|
2336
|
+
console.print("[dim]Run 'axion logout --provider openai' to clear and re-auth.[/dim]")
|
|
2337
|
+
return 0
|
|
2338
|
+
|
|
2339
|
+
console.print("[dim]This will open your browser to auth.openai.com.[/dim]")
|
|
2340
|
+
console.print("[dim]Codex requests will then use your ChatGPT plan instead of API billing.[/dim]")
|
|
2341
|
+
console.print("[dim]A local server runs briefly on port 1455 to receive the callback.[/dim]\n")
|
|
2342
|
+
|
|
2343
|
+
console.print("Opening browser...\n")
|
|
2344
|
+
result = await login_with_openai_subscription()
|
|
2345
|
+
|
|
2346
|
+
if result.success:
|
|
2347
|
+
plan_text = f" ({result.plan})" if result.plan else ""
|
|
2348
|
+
console.print(f"\n[bold green]Logged in to ChatGPT{plan_text}![/bold green]")
|
|
2349
|
+
console.print("[dim]Tokens saved to ~/.axion/credentials/openai-oauth.json[/dim]")
|
|
2350
|
+
console.print(
|
|
2351
|
+
"\nRun [cyan]axion -m codex[/cyan] (or [cyan]/model codex[/cyan]) to use Codex via your ChatGPT plan."
|
|
2352
|
+
)
|
|
2353
|
+
return 0
|
|
2354
|
+
else:
|
|
2355
|
+
logout_openai_subscription()
|
|
2356
|
+
console.print(f"\n[red]Login failed:[/red] {result.error}")
|
|
2357
|
+
console.print("[dim]Try again, or use an API key with [bold]axion login --provider openai[/bold].[/dim]")
|
|
2358
|
+
return 1
|
|
2359
|
+
|
|
2360
|
+
|
|
2361
|
+
async def _run_login(provider_name: str = "anthropic", subscription: bool = False) -> int:
|
|
2362
|
+
"""Log in by entering an API key (saved permanently) or via OAuth."""
|
|
2363
|
+
if subscription:
|
|
2364
|
+
if provider_name == "anthropic":
|
|
2365
|
+
return await _run_subscription_login()
|
|
2366
|
+
if provider_name == "openai":
|
|
2367
|
+
return await _run_openai_subscription_login()
|
|
2368
|
+
console.print(
|
|
2369
|
+
f"[red]Subscription login is only available for Anthropic and OpenAI, not {provider_name}.[/red]"
|
|
2370
|
+
)
|
|
2371
|
+
return 1
|
|
2372
|
+
|
|
2373
|
+
console.print("[bold]Axion Code Login[/bold]\n")
|
|
2374
|
+
|
|
2375
|
+
# Check for existing saved key
|
|
2376
|
+
key_path = Path.home() / ".axion" / "credentials" / f"{provider_name}.key"
|
|
2377
|
+
if key_path.exists():
|
|
2378
|
+
saved_key = key_path.read_text(encoding="utf-8").strip()
|
|
2379
|
+
if saved_key:
|
|
2380
|
+
masked = saved_key[:8] + "..." + saved_key[-4:]
|
|
2381
|
+
console.print(f"[green]Already logged in.[/green] Key: {masked}")
|
|
2382
|
+
console.print("[dim]Use 'axion logout' to clear credentials.[/dim]")
|
|
2383
|
+
return 0
|
|
2384
|
+
|
|
2385
|
+
# Check env var
|
|
2386
|
+
env_vars = {
|
|
2387
|
+
"anthropic": "ANTHROPIC_API_KEY",
|
|
2388
|
+
"openai": "OPENAI_API_KEY",
|
|
2389
|
+
"xai": "XAI_API_KEY",
|
|
2390
|
+
}
|
|
2391
|
+
env_var = env_vars.get(provider_name, "ANTHROPIC_API_KEY")
|
|
2392
|
+
existing_env = os.environ.get(env_var)
|
|
2393
|
+
if existing_env:
|
|
2394
|
+
console.print(f"[green]{env_var} is already set in your environment.[/green]")
|
|
2395
|
+
console.print("[dim]Want to save it permanently? Enter 'save' below, or enter a different key.[/dim]\n")
|
|
2396
|
+
|
|
2397
|
+
# For Anthropic, offer the subscription option as well
|
|
2398
|
+
if provider_name == "anthropic" and not existing_env:
|
|
2399
|
+
console.print("[bold]How do you want to use Claude?[/bold]\n")
|
|
2400
|
+
console.print(" [cyan]1.[/cyan] Subscription (Claude Pro/Max) — uses your $20-200/mo plan")
|
|
2401
|
+
console.print(" [dim]Best if you have a Claude subscription, no per-token billing[/dim]")
|
|
2402
|
+
console.print(" [cyan]2.[/cyan] API key (pay-per-token)")
|
|
2403
|
+
console.print(" [dim]Best for occasional use or if you don't have a subscription[/dim]")
|
|
2404
|
+
console.print()
|
|
2405
|
+
try:
|
|
2406
|
+
choice = console.input("[cyan]Choose [1/2]: [/cyan]").strip()
|
|
2407
|
+
except (EOFError, KeyboardInterrupt):
|
|
2408
|
+
console.print("\n[dim]Cancelled.[/dim]")
|
|
2409
|
+
return 1
|
|
2410
|
+
if choice == "1":
|
|
2411
|
+
return await _run_subscription_login()
|
|
2412
|
+
# else fall through to API key flow
|
|
2413
|
+
|
|
2414
|
+
# Provider-specific info
|
|
2415
|
+
provider_info = {
|
|
2416
|
+
"anthropic": {
|
|
2417
|
+
"display": "Anthropic (Claude)",
|
|
2418
|
+
"url": "https://console.anthropic.com/settings/keys",
|
|
2419
|
+
"prefix": "sk-ant-",
|
|
2420
|
+
"models": "opus, sonnet, haiku",
|
|
2421
|
+
},
|
|
2422
|
+
"openai": {
|
|
2423
|
+
"display": "OpenAI (GPT)",
|
|
2424
|
+
"url": "https://platform.openai.com/api-keys",
|
|
2425
|
+
"prefix": "sk-",
|
|
2426
|
+
"models": "gpt-4o, o1, o3",
|
|
2427
|
+
},
|
|
2428
|
+
"xai": {
|
|
2429
|
+
"display": "xAI (Grok)",
|
|
2430
|
+
"url": "https://console.x.ai",
|
|
2431
|
+
"prefix": "xai-",
|
|
2432
|
+
"models": "grok-2",
|
|
2433
|
+
},
|
|
2434
|
+
}
|
|
2435
|
+
info = provider_info.get(provider_name, provider_info["anthropic"])
|
|
2436
|
+
|
|
2437
|
+
# Prompt for API key
|
|
2438
|
+
console.print(f"Provider: [bold]{info['display']}[/bold]")
|
|
2439
|
+
console.print(f"Models: [dim]{info['models']}[/dim]")
|
|
2440
|
+
console.print()
|
|
2441
|
+
console.print("Enter your API key (or 'save' to save the current env key):")
|
|
2442
|
+
console.print(f" Get one at: [link]{info['url']}[/link]")
|
|
2443
|
+
console.print()
|
|
2444
|
+
|
|
2445
|
+
try:
|
|
2446
|
+
answer = console.input("[cyan]API key: [/cyan]").strip()
|
|
2447
|
+
except (EOFError, KeyboardInterrupt):
|
|
2448
|
+
console.print("\n[dim]Cancelled.[/dim]")
|
|
2449
|
+
return 1
|
|
2450
|
+
|
|
2451
|
+
if not answer:
|
|
2452
|
+
console.print("[yellow]No key entered.[/yellow]")
|
|
2453
|
+
return 1
|
|
2454
|
+
|
|
2455
|
+
# Handle 'save' — persist the env var key
|
|
2456
|
+
if answer.lower() == "save" and existing_env:
|
|
2457
|
+
answer = existing_env
|
|
2458
|
+
|
|
2459
|
+
# Save the key permanently
|
|
2460
|
+
key_path.parent.mkdir(parents=True, exist_ok=True)
|
|
2461
|
+
key_path.write_text(answer, encoding="utf-8")
|
|
2462
|
+
try:
|
|
2463
|
+
os.chmod(key_path, 0o600)
|
|
2464
|
+
except OSError:
|
|
2465
|
+
pass
|
|
2466
|
+
|
|
2467
|
+
# Also set it in the current process so it works immediately
|
|
2468
|
+
os.environ[env_var] = answer
|
|
2469
|
+
|
|
2470
|
+
masked = answer[:8] + "..." + answer[-4:]
|
|
2471
|
+
console.print(f"\n[green]Key saved![/green] ({masked})")
|
|
2472
|
+
console.print(f"Stored at: [dim]{key_path}[/dim]")
|
|
2473
|
+
|
|
2474
|
+
# Show how to use it
|
|
2475
|
+
if provider_name == "anthropic":
|
|
2476
|
+
console.print("\n[bold]You're ready to go![/bold] Run [cyan]axion[/cyan] to start.")
|
|
2477
|
+
elif provider_name == "openai":
|
|
2478
|
+
console.print("\n[bold]You're ready to go![/bold] Run [cyan]axion -m gpt-4o[/cyan] to start.")
|
|
2479
|
+
elif provider_name == "xai":
|
|
2480
|
+
console.print("\n[bold]You're ready to go![/bold] Run [cyan]axion -m grok-2[/cyan] to start.")
|
|
2481
|
+
return 0
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def _run_logout(provider_name: str = "anthropic") -> int:
|
|
2485
|
+
"""Clear all stored credentials (API key + OAuth)."""
|
|
2486
|
+
console.print("[bold]Axion Code Logout[/bold]\n")
|
|
2487
|
+
cleared = False
|
|
2488
|
+
|
|
2489
|
+
# Clear saved API key
|
|
2490
|
+
key_path = Path.home() / ".axion" / "credentials" / f"{provider_name}.key"
|
|
2491
|
+
if key_path.exists():
|
|
2492
|
+
key_path.unlink()
|
|
2493
|
+
console.print(f"[green]Removed saved API key: {key_path}[/green]")
|
|
2494
|
+
cleared = True
|
|
2495
|
+
|
|
2496
|
+
# Clear OAuth credentials
|
|
2497
|
+
existing = load_oauth_credentials(provider_name)
|
|
2498
|
+
if existing is not None:
|
|
2499
|
+
clear_oauth_credentials(provider_name)
|
|
2500
|
+
console.print("[green]Cleared OAuth credentials.[/green]")
|
|
2501
|
+
cleared = True
|
|
2502
|
+
|
|
2503
|
+
# Clear env var for this process
|
|
2504
|
+
env_vars = {"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY", "xai": "XAI_API_KEY"}
|
|
2505
|
+
env_var = env_vars.get(provider_name)
|
|
2506
|
+
if env_var and env_var in os.environ:
|
|
2507
|
+
del os.environ[env_var]
|
|
2508
|
+
console.print(f"[green]Cleared {env_var} from current session.[/green]")
|
|
2509
|
+
cleared = True
|
|
2510
|
+
|
|
2511
|
+
if not cleared:
|
|
2512
|
+
console.print("[dim]No stored credentials found.[/dim]")
|
|
2513
|
+
|
|
2514
|
+
return 0
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
# ---------------------------------------------------------------------------
|
|
2518
|
+
# Click CLI definition
|
|
2519
|
+
# ---------------------------------------------------------------------------
|
|
2520
|
+
|
|
2521
|
+
@click.group(invoke_without_command=True)
|
|
2522
|
+
@click.option("--model", "-m", default=None, help="Model to use (auto-detects from saved keys)")
|
|
2523
|
+
@click.option(
|
|
2524
|
+
"--permission-mode",
|
|
2525
|
+
type=click.Choice(["allow", "read-only", "workspace-write", "danger-full-access", "prompt"]),
|
|
2526
|
+
default="allow",
|
|
2527
|
+
help="Permission mode for tool execution",
|
|
2528
|
+
)
|
|
2529
|
+
@click.option("--prompt", "-p", default=None, help="One-shot prompt (non-interactive)")
|
|
2530
|
+
@click.option("--version", "-v", is_flag=True, help="Show version")
|
|
2531
|
+
@click.option(
|
|
2532
|
+
"--output-format",
|
|
2533
|
+
type=click.Choice(["text", "json"]),
|
|
2534
|
+
default="text",
|
|
2535
|
+
help="Output format (text or json)",
|
|
2536
|
+
)
|
|
2537
|
+
@click.option("--resume", "-r", default=None, help="Resume session (ID, path, or 'latest')")
|
|
2538
|
+
@click.option("--verbose", is_flag=True, help="Enable verbose logging")
|
|
2539
|
+
@click.option("--system-prompt", "system_prompt_file", default=None, type=click.Path(exists=True),
|
|
2540
|
+
help="Path to a custom system prompt file")
|
|
2541
|
+
@click.option("--budget", default=None, type=float,
|
|
2542
|
+
help="Max cost budget in USD for this session (e.g. --budget 1.00)")
|
|
2543
|
+
@click.pass_context
|
|
2544
|
+
def cli(
|
|
2545
|
+
ctx: click.Context,
|
|
2546
|
+
model: str | None,
|
|
2547
|
+
permission_mode: str,
|
|
2548
|
+
prompt: str | None,
|
|
2549
|
+
version: bool,
|
|
2550
|
+
output_format: str,
|
|
2551
|
+
resume: str | None,
|
|
2552
|
+
verbose: bool,
|
|
2553
|
+
system_prompt_file: str | None,
|
|
2554
|
+
budget: float | None,
|
|
2555
|
+
) -> None:
|
|
2556
|
+
"""Axion Code - AI coding assistant for your terminal.
|
|
2557
|
+
|
|
2558
|
+
Supports Claude, GPT, Grok, and local models via Ollama.
|
|
2559
|
+
|
|
2560
|
+
Run without arguments for interactive REPL mode, or pass --prompt/-p for
|
|
2561
|
+
one-shot execution. Use subcommands for specific operations.
|
|
2562
|
+
"""
|
|
2563
|
+
if verbose:
|
|
2564
|
+
logging.basicConfig(level=logging.DEBUG, format="%(name)s %(levelname)s %(message)s")
|
|
2565
|
+
else:
|
|
2566
|
+
logging.basicConfig(level=logging.WARNING)
|
|
2567
|
+
|
|
2568
|
+
if version:
|
|
2569
|
+
click.echo(f"axion-code {__version__}")
|
|
2570
|
+
return
|
|
2571
|
+
|
|
2572
|
+
# Store shared state on context for subcommands
|
|
2573
|
+
ctx.ensure_object(dict)
|
|
2574
|
+
ctx.obj["model"] = model
|
|
2575
|
+
ctx.obj["permission_mode"] = permission_mode
|
|
2576
|
+
ctx.obj["output_format"] = output_format
|
|
2577
|
+
ctx.obj["system_prompt_file"] = system_prompt_file
|
|
2578
|
+
|
|
2579
|
+
if ctx.invoked_subcommand is not None:
|
|
2580
|
+
return
|
|
2581
|
+
|
|
2582
|
+
# Auto-detect model if not specified
|
|
2583
|
+
effective_model = model or _auto_detect_model()
|
|
2584
|
+
|
|
2585
|
+
try:
|
|
2586
|
+
if prompt:
|
|
2587
|
+
exit_code = asyncio.run(run_one_shot(prompt, effective_model, permission_mode, output_format))
|
|
2588
|
+
else:
|
|
2589
|
+
exit_code = asyncio.run(run_repl(effective_model, permission_mode, resume, output_format, budget))
|
|
2590
|
+
except Exception as exc:
|
|
2591
|
+
error_msg = str(exc)
|
|
2592
|
+
if "credentials" in error_msg.lower() or "api key" in error_msg.lower():
|
|
2593
|
+
console.print()
|
|
2594
|
+
console.print("[bold red]No API key configured.[/bold red]")
|
|
2595
|
+
console.print()
|
|
2596
|
+
console.print("Quick setup:")
|
|
2597
|
+
console.print(" [cyan]axion login[/cyan] (paste your API key, saved permanently)")
|
|
2598
|
+
console.print()
|
|
2599
|
+
console.print("Or set an environment variable:")
|
|
2600
|
+
console.print(" [cyan]$env:ANTHROPIC_API_KEY=\"sk-ant-...\"[/cyan] (PowerShell)")
|
|
2601
|
+
console.print(" [cyan]export ANTHROPIC_API_KEY=sk-ant-...[/cyan] (Linux/Mac)")
|
|
2602
|
+
console.print()
|
|
2603
|
+
console.print("Or use a local model with Ollama (free, no key needed):")
|
|
2604
|
+
console.print(" [cyan]ollama pull llama3.1[/cyan]")
|
|
2605
|
+
console.print(" [cyan]axion -m llama3.1[/cyan]")
|
|
2606
|
+
console.print()
|
|
2607
|
+
console.print("Run [bold]axion doctor[/bold] to check your setup.")
|
|
2608
|
+
exit_code = 1
|
|
2609
|
+
else:
|
|
2610
|
+
console.print(f"[red]Error: {error_msg}[/red]")
|
|
2611
|
+
exit_code = 1
|
|
2612
|
+
|
|
2613
|
+
sys.exit(exit_code)
|
|
2614
|
+
|
|
2615
|
+
|
|
2616
|
+
# ---------------------------------------------------------------------------
|
|
2617
|
+
# Subcommands
|
|
2618
|
+
# ---------------------------------------------------------------------------
|
|
2619
|
+
|
|
2620
|
+
@cli.command()
|
|
2621
|
+
@click.pass_context
|
|
2622
|
+
def status(ctx: click.Context) -> None:
|
|
2623
|
+
"""Show current environment status."""
|
|
2624
|
+
console.print(f"[bold]Axion Code[/bold] v{__version__}")
|
|
2625
|
+
console.print(f"Working directory: {Path.cwd()}")
|
|
2626
|
+
|
|
2627
|
+
model = ctx.obj.get("model", DEFAULT_MODEL) if ctx.obj else DEFAULT_MODEL
|
|
2628
|
+
console.print(f"Model: [cyan]{resolve_model_alias(model)}[/cyan]")
|
|
2629
|
+
|
|
2630
|
+
branch = _git_branch()
|
|
2631
|
+
if branch:
|
|
2632
|
+
console.print(f"Git branch: {branch}")
|
|
2633
|
+
|
|
2634
|
+
git_st = _git_status_short()
|
|
2635
|
+
if git_st:
|
|
2636
|
+
console.print(f"Git status: {git_st}")
|
|
2637
|
+
|
|
2638
|
+
config = _load_config()
|
|
2639
|
+
console.print(f"Config sources: {len(config.loaded_entries)}")
|
|
2640
|
+
|
|
2641
|
+
# API key status
|
|
2642
|
+
has_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
|
2643
|
+
oauth = load_oauth_credentials("anthropic")
|
|
2644
|
+
if has_key:
|
|
2645
|
+
console.print("Auth: [green]API key set[/green]")
|
|
2646
|
+
elif oauth and not oauth.is_expired():
|
|
2647
|
+
console.print("Auth: [green]OAuth (valid)[/green]")
|
|
2648
|
+
else:
|
|
2649
|
+
console.print("Auth: [yellow]Not configured[/yellow]")
|
|
2650
|
+
|
|
2651
|
+
# Sandbox
|
|
2652
|
+
sandbox = detect_sandbox()
|
|
2653
|
+
console.print(f"Sandbox: {'available' if sandbox.available else 'not available'} ({sandbox.details})")
|
|
2654
|
+
|
|
2655
|
+
# Sessions
|
|
2656
|
+
sessions = _list_sessions()
|
|
2657
|
+
console.print(f"Saved sessions: {len(sessions)}")
|
|
2658
|
+
|
|
2659
|
+
|
|
2660
|
+
@cli.command()
|
|
2661
|
+
def sandbox() -> None:
|
|
2662
|
+
"""Show sandbox status and capabilities."""
|
|
2663
|
+
status = detect_sandbox()
|
|
2664
|
+
console.print("[bold]Sandbox Status[/bold]\n")
|
|
2665
|
+
console.print(f" Available: {'yes' if status.available else 'no'}")
|
|
2666
|
+
console.print(f" Enabled: {'yes' if status.enabled else 'no'}")
|
|
2667
|
+
console.print(f" Platform: {status.platform}")
|
|
2668
|
+
console.print(f" Details: {status.details}")
|
|
2669
|
+
|
|
2670
|
+
|
|
2671
|
+
@cli.command()
|
|
2672
|
+
@click.argument("args", nargs=-1)
|
|
2673
|
+
def agents(args: tuple[str, ...]) -> None:
|
|
2674
|
+
"""List and manage available agents."""
|
|
2675
|
+
args_str = " ".join(args)
|
|
2676
|
+
result = handle_agents_command(args_str)
|
|
2677
|
+
console.print(result)
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
@cli.command()
|
|
2681
|
+
@click.argument("args", nargs=-1)
|
|
2682
|
+
def mcp(args: tuple[str, ...]) -> None:
|
|
2683
|
+
"""Manage MCP (Model Context Protocol) servers."""
|
|
2684
|
+
args_str = " ".join(args)
|
|
2685
|
+
result = handle_mcp_command(args_str)
|
|
2686
|
+
console.print(result)
|
|
2687
|
+
|
|
2688
|
+
|
|
2689
|
+
@cli.command()
|
|
2690
|
+
@click.argument("args", nargs=-1)
|
|
2691
|
+
def skills(args: tuple[str, ...]) -> None:
|
|
2692
|
+
"""List available skills."""
|
|
2693
|
+
args_str = " ".join(args)
|
|
2694
|
+
result = handle_skills_command(args_str)
|
|
2695
|
+
console.print(result)
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
@cli.command()
|
|
2699
|
+
@click.argument("args", nargs=-1)
|
|
2700
|
+
def plugins(args: tuple[str, ...]) -> None:
|
|
2701
|
+
"""Manage plugins."""
|
|
2702
|
+
args_str = " ".join(args)
|
|
2703
|
+
manager = _create_plugin_manager()
|
|
2704
|
+
result = handle_plugins_command(args_str, manager)
|
|
2705
|
+
console.print(result)
|
|
2706
|
+
|
|
2707
|
+
|
|
2708
|
+
@cli.command(name="system-prompt")
|
|
2709
|
+
@click.option("--file", "-f", "file_path", default=None, type=click.Path(exists=True),
|
|
2710
|
+
help="Load system prompt from file")
|
|
2711
|
+
@click.pass_context
|
|
2712
|
+
def system_prompt_cmd(ctx: click.Context, file_path: str | None) -> None:
|
|
2713
|
+
"""Show or set the system prompt."""
|
|
2714
|
+
if file_path:
|
|
2715
|
+
content = Path(file_path).read_text(encoding="utf-8")
|
|
2716
|
+
console.print(f"[dim]System prompt from {file_path}:[/dim]\n")
|
|
2717
|
+
console.print(Markdown(content))
|
|
2718
|
+
else:
|
|
2719
|
+
builder = SystemPromptBuilder.for_cwd()
|
|
2720
|
+
prompt_text = builder.build()
|
|
2721
|
+
console.print("[bold]Current system prompt:[/bold]\n")
|
|
2722
|
+
# Truncate very long prompts for display
|
|
2723
|
+
if len(prompt_text) > 5000:
|
|
2724
|
+
console.print(prompt_text[:5000])
|
|
2725
|
+
console.print(f"\n[dim]... ({len(prompt_text)} chars total, truncated)[/dim]")
|
|
2726
|
+
else:
|
|
2727
|
+
console.print(prompt_text)
|
|
2728
|
+
|
|
2729
|
+
|
|
2730
|
+
@cli.command()
|
|
2731
|
+
@click.option("--provider", default="anthropic", help="Provider (anthropic, openai, xai)")
|
|
2732
|
+
@click.option("--subscription", is_flag=True, help="Log in with Claude Pro/Max subscription (OAuth)")
|
|
2733
|
+
def login(provider: str, subscription: bool) -> None:
|
|
2734
|
+
"""Log in via API key or Claude subscription OAuth."""
|
|
2735
|
+
exit_code = asyncio.run(_run_login(provider, subscription=subscription))
|
|
2736
|
+
sys.exit(exit_code)
|
|
2737
|
+
|
|
2738
|
+
|
|
2739
|
+
@cli.command()
|
|
2740
|
+
@click.option("--provider", default="anthropic", help="Provider (anthropic, openai, xai)")
|
|
2741
|
+
def logout(provider: str) -> None:
|
|
2742
|
+
"""Log out and clear stored credentials (both API key and subscription)."""
|
|
2743
|
+
# Also clear subscription credentials for the matching provider
|
|
2744
|
+
if provider == "anthropic":
|
|
2745
|
+
try:
|
|
2746
|
+
from axion.runtime.claude_subscription import (
|
|
2747
|
+
has_subscription_credentials,
|
|
2748
|
+
logout_subscription,
|
|
2749
|
+
)
|
|
2750
|
+
if has_subscription_credentials():
|
|
2751
|
+
logout_subscription()
|
|
2752
|
+
console.print("[green]Cleared Claude subscription credentials.[/green]")
|
|
2753
|
+
except Exception:
|
|
2754
|
+
pass
|
|
2755
|
+
elif provider == "openai":
|
|
2756
|
+
try:
|
|
2757
|
+
from axion.runtime.openai_subscription import (
|
|
2758
|
+
has_openai_subscription_credentials,
|
|
2759
|
+
logout_openai_subscription,
|
|
2760
|
+
)
|
|
2761
|
+
if has_openai_subscription_credentials():
|
|
2762
|
+
logout_openai_subscription()
|
|
2763
|
+
console.print("[green]Cleared ChatGPT subscription credentials.[/green]")
|
|
2764
|
+
except Exception:
|
|
2765
|
+
pass
|
|
2766
|
+
exit_code = _run_logout(provider)
|
|
2767
|
+
sys.exit(exit_code)
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
@cli.command()
|
|
2771
|
+
def doctor() -> None:
|
|
2772
|
+
"""Run health checks on the environment."""
|
|
2773
|
+
result = _run_doctor_checks()
|
|
2774
|
+
console.print(result)
|
|
2775
|
+
|
|
2776
|
+
|
|
2777
|
+
@cli.command()
|
|
2778
|
+
def init() -> None:
|
|
2779
|
+
"""Initialize a new project with CLAUDE.md."""
|
|
2780
|
+
result = _handle_init_command()
|
|
2781
|
+
console.print(result)
|
|
2782
|
+
|
|
2783
|
+
|
|
2784
|
+
@cli.command(name="version")
|
|
2785
|
+
def version_cmd() -> None:
|
|
2786
|
+
"""Show version information."""
|
|
2787
|
+
click.echo(f"axion-code {__version__}")
|
|
2788
|
+
click.echo(f"Python {sys.version.split()[0]}")
|
|
2789
|
+
click.echo(f"Platform: {sys.platform}")
|
|
2790
|
+
|
|
2791
|
+
|
|
2792
|
+
@cli.command()
|
|
2793
|
+
@click.argument("session_id", default="latest")
|
|
2794
|
+
@click.option("--model", "-m", default=None, help="Model to use (auto-detects from saved keys)")
|
|
2795
|
+
@click.option(
|
|
2796
|
+
"--permission-mode",
|
|
2797
|
+
type=click.Choice(["allow", "read-only", "workspace-write", "danger-full-access", "prompt"]),
|
|
2798
|
+
default="allow",
|
|
2799
|
+
)
|
|
2800
|
+
def resume(session_id: str, model: str, permission_mode: str) -> None:
|
|
2801
|
+
"""Resume a previous session.
|
|
2802
|
+
|
|
2803
|
+
SESSION_ID can be a full session ID, partial ID, file path, or 'latest'.
|
|
2804
|
+
"""
|
|
2805
|
+
exit_code = asyncio.run(run_repl(model, permission_mode, resume=session_id))
|
|
2806
|
+
sys.exit(exit_code)
|
|
2807
|
+
|
|
2808
|
+
|
|
2809
|
+
@cli.command()
|
|
2810
|
+
@click.argument("session_id", default="latest")
|
|
2811
|
+
@click.option("--output", "-o", default=None, help="Output file path")
|
|
2812
|
+
def export(session_id: str, output: str | None) -> None:
|
|
2813
|
+
"""Export a session transcript to markdown.
|
|
2814
|
+
|
|
2815
|
+
SESSION_ID can be a session ID, partial ID, file path, or 'latest'.
|
|
2816
|
+
"""
|
|
2817
|
+
path = _resolve_session(session_id)
|
|
2818
|
+
if path is None:
|
|
2819
|
+
console.print(f"[red]Session not found: {session_id}[/red]")
|
|
2820
|
+
sys.exit(1)
|
|
2821
|
+
|
|
2822
|
+
try:
|
|
2823
|
+
session = Session.load(path)
|
|
2824
|
+
except Exception as exc:
|
|
2825
|
+
console.print(f"[red]Failed to load session: {exc}[/red]")
|
|
2826
|
+
sys.exit(1)
|
|
2827
|
+
|
|
2828
|
+
output_path = Path(output) if output else Path.cwd() / f"transcript-{session.session_id}.md"
|
|
2829
|
+
_export_transcript(session, output_path)
|
|
2830
|
+
console.print(f"[green]Exported to: {output_path}[/green]")
|
|
2831
|
+
|
|
2832
|
+
|
|
2833
|
+
@cli.command()
|
|
2834
|
+
def config() -> None:
|
|
2835
|
+
"""Show merged configuration from all sources."""
|
|
2836
|
+
cfg = _load_config()
|
|
2837
|
+
result = _format_config(cfg)
|
|
2838
|
+
console.print(result)
|
|
2839
|
+
|
|
2840
|
+
|
|
2841
|
+
@cli.command(name="session")
|
|
2842
|
+
@click.argument("action", default="list")
|
|
2843
|
+
@click.argument("args", nargs=-1)
|
|
2844
|
+
def session_cmd(action: str, args: tuple[str, ...]) -> None:
|
|
2845
|
+
"""Manage sessions (list, show, delete).
|
|
2846
|
+
|
|
2847
|
+
\b
|
|
2848
|
+
Actions:
|
|
2849
|
+
list - List saved sessions
|
|
2850
|
+
show - Show session details
|
|
2851
|
+
delete - Delete a session
|
|
2852
|
+
"""
|
|
2853
|
+
if action == "list":
|
|
2854
|
+
files = _list_sessions()
|
|
2855
|
+
if not files:
|
|
2856
|
+
console.print("[dim]No saved sessions.[/dim]")
|
|
2857
|
+
return
|
|
2858
|
+
console.print("[bold]Saved sessions:[/bold]\n")
|
|
2859
|
+
for f in files:
|
|
2860
|
+
mod_time = datetime.fromtimestamp(f.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
2861
|
+
size_kb = f.stat().st_size / 1024
|
|
2862
|
+
console.print(f" {f.stem} [dim]{mod_time} ({size_kb:.1f} KB)[/dim]")
|
|
2863
|
+
|
|
2864
|
+
elif action == "show":
|
|
2865
|
+
if not args:
|
|
2866
|
+
console.print("[yellow]Usage: axion session show <session_id>[/yellow]")
|
|
2867
|
+
return
|
|
2868
|
+
path = _resolve_session(args[0])
|
|
2869
|
+
if path is None:
|
|
2870
|
+
console.print(f"[red]Session not found: {args[0]}[/red]")
|
|
2871
|
+
return
|
|
2872
|
+
try:
|
|
2873
|
+
session = Session.load(path)
|
|
2874
|
+
console.print(f"[bold]Session: {session.session_id}[/bold]")
|
|
2875
|
+
console.print(f" Created: {datetime.fromtimestamp(session.created_at_ms / 1000).isoformat()}")
|
|
2876
|
+
console.print(f" Updated: {datetime.fromtimestamp(session.updated_at_ms / 1000).isoformat()}")
|
|
2877
|
+
console.print(f" Messages: {session.message_count()}")
|
|
2878
|
+
if session.compaction:
|
|
2879
|
+
console.print(f" Compactions: {session.compaction.count}")
|
|
2880
|
+
if session.fork:
|
|
2881
|
+
console.print(f" Forked from: {session.fork.parent_session_id}")
|
|
2882
|
+
|
|
2883
|
+
# Show message summary
|
|
2884
|
+
console.print("\n[bold]Messages:[/bold]")
|
|
2885
|
+
for i, msg in enumerate(session.messages):
|
|
2886
|
+
role = msg.role.value.upper()
|
|
2887
|
+
block_types = [type(b).__name__ for b in msg.blocks]
|
|
2888
|
+
preview = ""
|
|
2889
|
+
for b in msg.blocks:
|
|
2890
|
+
if isinstance(b, TextBlock):
|
|
2891
|
+
preview = b.text[:80].replace("\n", " ")
|
|
2892
|
+
if len(b.text) > 80:
|
|
2893
|
+
preview += "..."
|
|
2894
|
+
break
|
|
2895
|
+
console.print(f" [{i}] {role} ({', '.join(block_types)}): {preview}")
|
|
2896
|
+
except Exception as exc:
|
|
2897
|
+
console.print(f"[red]Failed to load session: {exc}[/red]")
|
|
2898
|
+
|
|
2899
|
+
elif action == "delete":
|
|
2900
|
+
if not args:
|
|
2901
|
+
console.print("[yellow]Usage: axion session delete <session_id>[/yellow]")
|
|
2902
|
+
return
|
|
2903
|
+
path = _resolve_session(args[0])
|
|
2904
|
+
if path is None:
|
|
2905
|
+
console.print(f"[red]Session not found: {args[0]}[/red]")
|
|
2906
|
+
return
|
|
2907
|
+
try:
|
|
2908
|
+
path.unlink()
|
|
2909
|
+
console.print(f"[green]Deleted session: {path.stem}[/green]")
|
|
2910
|
+
except Exception as exc:
|
|
2911
|
+
console.print(f"[red]Failed to delete: {exc}[/red]")
|
|
2912
|
+
|
|
2913
|
+
else:
|
|
2914
|
+
console.print(f"[yellow]Unknown action: {action}. Use: list, show, delete[/yellow]")
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
@cli.command(name="prompt")
|
|
2918
|
+
@click.argument("prompt_text")
|
|
2919
|
+
@click.option("--model", "-m", default=None)
|
|
2920
|
+
@click.option(
|
|
2921
|
+
"--output-format",
|
|
2922
|
+
type=click.Choice(["text", "json"]),
|
|
2923
|
+
default="text",
|
|
2924
|
+
)
|
|
2925
|
+
def prompt_cmd(prompt_text: str, model: str, output_format: str) -> None:
|
|
2926
|
+
"""Send a one-shot prompt."""
|
|
2927
|
+
exit_code = asyncio.run(run_one_shot(prompt_text, model, "allow", output_format))
|
|
2928
|
+
sys.exit(exit_code)
|
|
2929
|
+
|
|
2930
|
+
|
|
2931
|
+
@cli.command()
|
|
2932
|
+
def tools() -> None:
|
|
2933
|
+
"""List all available tools."""
|
|
2934
|
+
registry = get_tool_registry()
|
|
2935
|
+
console.print("[bold]Available tools:[/bold]\n")
|
|
2936
|
+
for tool_def in registry.all_tools():
|
|
2937
|
+
spec = tool_def.spec
|
|
2938
|
+
perm = spec.required_permission
|
|
2939
|
+
console.print(f" [bold]{spec.name}[/bold] [{tool_def.source}] (requires: {perm})")
|
|
2940
|
+
console.print(f" {spec.description[:100]}")
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
# ---------------------------------------------------------------------------
|
|
2944
|
+
# Entry point
|
|
2945
|
+
# ---------------------------------------------------------------------------
|
|
2946
|
+
|
|
2947
|
+
def main() -> None:
|
|
2948
|
+
"""Main entry point."""
|
|
2949
|
+
cli()
|
|
2950
|
+
|
|
2951
|
+
|
|
2952
|
+
if __name__ == "__main__":
|
|
2953
|
+
main()
|