axion-code 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/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()