axor-cli 0.1.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.
- axor_cli/__init__.py +3 -0
- axor_cli/_version.py +1 -0
- axor_cli/adapters.py +120 -0
- axor_cli/auth.py +189 -0
- axor_cli/display.py +185 -0
- axor_cli/main.py +314 -0
- axor_cli/streaming.py +108 -0
- axor_cli-0.1.0.dist-info/METADATA +275 -0
- axor_cli-0.1.0.dist-info/RECORD +12 -0
- axor_cli-0.1.0.dist-info/WHEEL +4 -0
- axor_cli-0.1.0.dist-info/entry_points.txt +2 -0
- axor_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
axor_cli/__init__.py
ADDED
axor_cli/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
axor_cli/adapters.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Adapter registry for axor-cli.
|
|
5
|
+
|
|
6
|
+
Adapters are loaded lazily — axor-claude / axor-openai are optional
|
|
7
|
+
dependencies. Missing adapter gives a helpful install message.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
from axor_core import GovernedSession, CapabilityExecutor
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Map of adapter name → module path + setup function
|
|
15
|
+
_REGISTRY: dict[str, dict[str, Any]] = {
|
|
16
|
+
"claude": {
|
|
17
|
+
"module": "axor_claude",
|
|
18
|
+
"install": "pip install axor-claude",
|
|
19
|
+
"env_var": "ANTHROPIC_API_KEY",
|
|
20
|
+
"models": ["claude-sonnet-4-5", "claude-opus-4-5", "claude-haiku-4-5"],
|
|
21
|
+
"default_model": "claude-sonnet-4-5",
|
|
22
|
+
},
|
|
23
|
+
"openai": {
|
|
24
|
+
"module": "axor_openai",
|
|
25
|
+
"install": "pip install axor-openai",
|
|
26
|
+
"env_var": "OPENAI_API_KEY",
|
|
27
|
+
"models": ["gpt-4o", "gpt-4o-mini"],
|
|
28
|
+
"default_model": "gpt-4o",
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def list_adapters() -> list[str]:
|
|
34
|
+
return list(_REGISTRY.keys())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_available(adapter: str) -> bool:
|
|
38
|
+
"""Check if adapter package is installed."""
|
|
39
|
+
info = _REGISTRY.get(adapter)
|
|
40
|
+
if not info:
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
__import__(info["module"])
|
|
44
|
+
return True
|
|
45
|
+
except ImportError:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def get_install_hint(adapter: str) -> str:
|
|
50
|
+
info = _REGISTRY.get(adapter, {})
|
|
51
|
+
return info.get("install", f"pip install axor-{adapter}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_session(
|
|
55
|
+
adapter: str,
|
|
56
|
+
api_key: str | None = None,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
tools: tuple[str, ...] = ("read", "write", "bash", "search", "glob"),
|
|
59
|
+
soft_token_limit: int | None = None,
|
|
60
|
+
system_prompt: str | None = None,
|
|
61
|
+
load_skills: bool = True,
|
|
62
|
+
load_plugins: bool = True,
|
|
63
|
+
) -> GovernedSession:
|
|
64
|
+
"""
|
|
65
|
+
Import the adapter package and build a GovernedSession.
|
|
66
|
+
Raises ImportError with install hint if package not installed.
|
|
67
|
+
"""
|
|
68
|
+
info = _REGISTRY.get(adapter)
|
|
69
|
+
if not info:
|
|
70
|
+
available = ", ".join(_REGISTRY.keys())
|
|
71
|
+
raise ValueError(f"Unknown adapter: '{adapter}'. Available: {available}")
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
mod = __import__(info["module"])
|
|
75
|
+
except ImportError:
|
|
76
|
+
raise ImportError(
|
|
77
|
+
f"Adapter '{adapter}' is not installed.\n"
|
|
78
|
+
f"Install it with: {info['install']}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# adapters expose make_session() as the standard factory
|
|
82
|
+
if not hasattr(mod, "make_session"):
|
|
83
|
+
raise AttributeError(
|
|
84
|
+
f"Adapter '{adapter}' ({info['module']}) does not expose make_session(). "
|
|
85
|
+
"Check your axor adapter version."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
kwargs: dict[str, Any] = {
|
|
89
|
+
"api_key": api_key,
|
|
90
|
+
"tools": tools,
|
|
91
|
+
"load_skills": load_skills,
|
|
92
|
+
"load_plugins": load_plugins,
|
|
93
|
+
}
|
|
94
|
+
if soft_token_limit is not None:
|
|
95
|
+
kwargs["soft_token_limit"] = soft_token_limit
|
|
96
|
+
|
|
97
|
+
# model and system_prompt are passed to the executor inside make_session
|
|
98
|
+
# adapters should accept **session_kwargs and forward to their executor
|
|
99
|
+
if model:
|
|
100
|
+
kwargs["model"] = model
|
|
101
|
+
if system_prompt:
|
|
102
|
+
kwargs["system_prompt"] = system_prompt
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return mod.make_session(**kwargs)
|
|
106
|
+
except ImportError as e:
|
|
107
|
+
# adapter's underlying SDK (e.g. anthropic) not installed
|
|
108
|
+
raise ImportError(
|
|
109
|
+
f"Adapter '{adapter}' requires additional dependencies.\n"
|
|
110
|
+
f" {e}\n"
|
|
111
|
+
f" Install with: {info['install']}"
|
|
112
|
+
) from e
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def default_model(adapter: str) -> str:
|
|
116
|
+
return _REGISTRY.get(adapter, {}).get("default_model", "unknown")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def available_models(adapter: str) -> list[str]:
|
|
120
|
+
return _REGISTRY.get(adapter, {}).get("models", [])
|
axor_cli/auth.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
API key management for axor-cli.
|
|
5
|
+
|
|
6
|
+
Priority order (highest to lowest):
|
|
7
|
+
1. --api-key CLI flag (one-off, never saved)
|
|
8
|
+
2. ADAPTER_API_KEY env var (e.g. ANTHROPIC_API_KEY)
|
|
9
|
+
3. ~/.axor/config.toml (persistent, 0600 permissions)
|
|
10
|
+
4. None → prompt via /auth
|
|
11
|
+
|
|
12
|
+
~/.axor/config.toml format:
|
|
13
|
+
[claude]
|
|
14
|
+
api_key = "sk-ant-..."
|
|
15
|
+
|
|
16
|
+
[openai]
|
|
17
|
+
api_key = "sk-..."
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import stat
|
|
22
|
+
import getpass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
import tomllib # Python 3.11+
|
|
28
|
+
except ImportError:
|
|
29
|
+
try:
|
|
30
|
+
import tomli as tomllib # fallback
|
|
31
|
+
except ImportError:
|
|
32
|
+
tomllib = None # type: ignore
|
|
33
|
+
|
|
34
|
+
CONFIG_DIR = Path.home() / ".axor"
|
|
35
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
36
|
+
|
|
37
|
+
# env var names per adapter
|
|
38
|
+
_ENV_VARS = {
|
|
39
|
+
"claude": "ANTHROPIC_API_KEY",
|
|
40
|
+
"openai": "OPENAI_API_KEY",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_api_key(adapter: str, flag_key: str | None = None) -> str | None:
|
|
45
|
+
"""
|
|
46
|
+
Resolve API key using priority chain.
|
|
47
|
+
Returns key string or None if not found.
|
|
48
|
+
"""
|
|
49
|
+
# 1. CLI flag
|
|
50
|
+
if flag_key:
|
|
51
|
+
return flag_key
|
|
52
|
+
|
|
53
|
+
# 2. env var
|
|
54
|
+
env_var = _ENV_VARS.get(adapter)
|
|
55
|
+
if env_var:
|
|
56
|
+
key = os.environ.get(env_var)
|
|
57
|
+
if key:
|
|
58
|
+
return key
|
|
59
|
+
|
|
60
|
+
# 3. config file
|
|
61
|
+
key = load_from_config(adapter)
|
|
62
|
+
if key:
|
|
63
|
+
# also set in env so axor-claude/axor-openai can pick it up
|
|
64
|
+
if env_var:
|
|
65
|
+
os.environ[env_var] = key
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_from_config(adapter: str) -> str | None:
|
|
72
|
+
"""Load API key from ~/.axor/config.toml."""
|
|
73
|
+
if not CONFIG_FILE.exists():
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
if tomllib is None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
81
|
+
config: dict[str, Any] = tomllib.load(f)
|
|
82
|
+
return config.get(adapter, {}).get("api_key")
|
|
83
|
+
except Exception:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_config(data: dict[str, Any]) -> None:
|
|
88
|
+
"""Write config dict to file atomically with 0600 permissions."""
|
|
89
|
+
import tempfile
|
|
90
|
+
lines = []
|
|
91
|
+
for section, values in data.items():
|
|
92
|
+
lines.append(f"[{section}]")
|
|
93
|
+
for key, val in values.items():
|
|
94
|
+
lines.append(f'{key} = "{val}"')
|
|
95
|
+
lines.append("")
|
|
96
|
+
|
|
97
|
+
# atomic write via temp file
|
|
98
|
+
fd, tmp = tempfile.mkstemp(dir=CONFIG_DIR, prefix=".axor_cfg_")
|
|
99
|
+
try:
|
|
100
|
+
with os.fdopen(fd, "w") as f:
|
|
101
|
+
f.write("\n".join(lines))
|
|
102
|
+
os.replace(tmp, CONFIG_FILE)
|
|
103
|
+
CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
104
|
+
except Exception:
|
|
105
|
+
if os.path.exists(tmp):
|
|
106
|
+
os.unlink(tmp)
|
|
107
|
+
raise
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def save_to_config(adapter: str, api_key: str) -> None:
|
|
111
|
+
"""Save API key to ~/.axor/config.toml with 0600 permissions."""
|
|
112
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
|
|
114
|
+
existing: dict[str, Any] = {}
|
|
115
|
+
if CONFIG_FILE.exists() and tomllib is not None:
|
|
116
|
+
try:
|
|
117
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
118
|
+
existing = tomllib.load(f)
|
|
119
|
+
except Exception:
|
|
120
|
+
existing = {}
|
|
121
|
+
|
|
122
|
+
if adapter not in existing:
|
|
123
|
+
existing[adapter] = {}
|
|
124
|
+
existing[adapter]["api_key"] = api_key
|
|
125
|
+
_write_config(existing)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def clear_from_config(adapter: str) -> bool:
|
|
129
|
+
"""Remove adapter key from config. Returns True if key existed."""
|
|
130
|
+
if not CONFIG_FILE.exists() or tomllib is None:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
135
|
+
existing: dict[str, Any] = tomllib.load(f)
|
|
136
|
+
except Exception:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
if adapter not in existing:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
del existing[adapter]
|
|
143
|
+
_write_config(existing)
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def prompt_and_save(adapter: str) -> str | None:
|
|
148
|
+
"""
|
|
149
|
+
Interactively prompt for API key.
|
|
150
|
+
Offers to save to config file.
|
|
151
|
+
Returns the key or None if user cancelled.
|
|
152
|
+
"""
|
|
153
|
+
env_var = _ENV_VARS.get(adapter, f"{adapter.upper()}_API_KEY")
|
|
154
|
+
|
|
155
|
+
print(f"\n No API key found for '{adapter}'.")
|
|
156
|
+
print(f" (checked: --api-key flag, {env_var} env var, {CONFIG_FILE})\n")
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
key = getpass.getpass(f" {adapter.capitalize()} API key (hidden): ").strip()
|
|
160
|
+
except (KeyboardInterrupt, EOFError):
|
|
161
|
+
print()
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if not key:
|
|
165
|
+
print(" No key entered.")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
# offer to save
|
|
169
|
+
try:
|
|
170
|
+
save = input(" Save to ~/.axor/config.toml for future sessions? [Y/n]: ").strip().lower()
|
|
171
|
+
except (KeyboardInterrupt, EOFError):
|
|
172
|
+
print()
|
|
173
|
+
save = "n"
|
|
174
|
+
|
|
175
|
+
if save in ("", "y", "yes"):
|
|
176
|
+
try:
|
|
177
|
+
save_to_config(adapter, key)
|
|
178
|
+
print(f" ✓ Key saved to {CONFIG_FILE} (permissions: 600)")
|
|
179
|
+
except Exception as e:
|
|
180
|
+
print(f" ✗ Could not save: {e}")
|
|
181
|
+
else:
|
|
182
|
+
print(" Key not saved — valid for this session only.")
|
|
183
|
+
|
|
184
|
+
# set in env for adapter to pick up
|
|
185
|
+
env_var_name = _ENV_VARS.get(adapter)
|
|
186
|
+
if env_var_name:
|
|
187
|
+
os.environ[env_var_name] = key
|
|
188
|
+
|
|
189
|
+
return key
|
axor_cli/display.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Terminal display helpers for axor-cli.
|
|
5
|
+
|
|
6
|
+
Handles:
|
|
7
|
+
- Streaming text output (print as chunks arrive)
|
|
8
|
+
- Status lines (policy, tokens, tools)
|
|
9
|
+
- Spinner for thinking state
|
|
10
|
+
- Colored output (degrades gracefully if no color support)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── Color support ──────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
def _supports_color() -> bool:
|
|
22
|
+
if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
|
|
23
|
+
return False
|
|
24
|
+
return os.environ.get("TERM", "") != "dumb"
|
|
25
|
+
|
|
26
|
+
_COLOR = _supports_color()
|
|
27
|
+
|
|
28
|
+
def _c(code: str, text: str) -> str:
|
|
29
|
+
if not _COLOR:
|
|
30
|
+
return text
|
|
31
|
+
return f"\033[{code}m{text}\033[0m"
|
|
32
|
+
|
|
33
|
+
def dim(text: str) -> str: return _c("2", text)
|
|
34
|
+
def bold(text: str) -> str: return _c("1", text)
|
|
35
|
+
def green(text: str) -> str: return _c("32", text)
|
|
36
|
+
def yellow(text: str)-> str: return _c("33", text)
|
|
37
|
+
def red(text: str) -> str: return _c("31", text)
|
|
38
|
+
def cyan(text: str) -> str: return _c("36", text)
|
|
39
|
+
def blue(text: str) -> str: return _c("34", text)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ── Header ─────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def print_header(adapter: str, model: str, version: str = "0.1.0") -> None:
|
|
45
|
+
print()
|
|
46
|
+
print(bold(f"axor") + f" v{version} " +
|
|
47
|
+
dim("│") + f" adapter: {cyan(adapter)} " +
|
|
48
|
+
dim("│") + f" model: {dim(model)}")
|
|
49
|
+
print(dim("Type a task, a /command, or 'exit' to quit."))
|
|
50
|
+
print(dim(" /auth — set API key"))
|
|
51
|
+
print(dim(" /cost — token usage"))
|
|
52
|
+
print(dim(" /policy — last execution policy"))
|
|
53
|
+
print(dim(" /compact — compress context"))
|
|
54
|
+
print(dim(" /status — session overview"))
|
|
55
|
+
print(dim(" /help — all commands"))
|
|
56
|
+
print()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── Spinner ────────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
class Spinner:
|
|
62
|
+
"""
|
|
63
|
+
Non-blocking spinner for "thinking" state.
|
|
64
|
+
Shows while waiting for first stream token.
|
|
65
|
+
Cleared as soon as text starts arriving.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
69
|
+
|
|
70
|
+
def __init__(self, prefix: str = "") -> None:
|
|
71
|
+
self._prefix = prefix
|
|
72
|
+
self._running = False
|
|
73
|
+
self._thread: threading.Thread | None = None
|
|
74
|
+
|
|
75
|
+
def start(self) -> None:
|
|
76
|
+
if not _COLOR:
|
|
77
|
+
sys.stdout.write(dim("thinking...\n"))
|
|
78
|
+
sys.stdout.flush()
|
|
79
|
+
return
|
|
80
|
+
self._running = True
|
|
81
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
82
|
+
self._thread.start()
|
|
83
|
+
|
|
84
|
+
def stop(self) -> None:
|
|
85
|
+
self._running = False
|
|
86
|
+
if self._thread:
|
|
87
|
+
self._thread.join(timeout=0.5)
|
|
88
|
+
if _COLOR:
|
|
89
|
+
sys.stdout.write("\r\033[K") # clear spinner line
|
|
90
|
+
sys.stdout.flush()
|
|
91
|
+
|
|
92
|
+
def _spin(self) -> None:
|
|
93
|
+
i = 0
|
|
94
|
+
while self._running:
|
|
95
|
+
frame = self._FRAMES[i % len(self._FRAMES)]
|
|
96
|
+
sys.stdout.write(f"\r{dim(self._prefix)}{dim(frame)} ")
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
time.sleep(0.08)
|
|
99
|
+
i += 1
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Streaming output ───────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def stream_text(text: str) -> None:
|
|
105
|
+
"""Print a text chunk as it arrives from the stream."""
|
|
106
|
+
sys.stdout.write(text)
|
|
107
|
+
sys.stdout.flush()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def print_tool_call(tool: str, args: dict, approved: bool) -> None:
|
|
111
|
+
if approved:
|
|
112
|
+
args_str = _format_args(args)
|
|
113
|
+
print(f"\n{dim(' ↳')} {yellow(tool)}{dim('('+ args_str +')')}", end="")
|
|
114
|
+
else:
|
|
115
|
+
print(f"\n{dim(' ✗')} {red(tool)} {dim('(denied)')}", end="")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def print_tool_result(tool: str, result: str, approved: bool) -> None:
|
|
119
|
+
if approved:
|
|
120
|
+
preview = result[:60].replace("\n", " ").strip()
|
|
121
|
+
ellipsis = "…" if len(result) > 60 else ""
|
|
122
|
+
print(f" {dim('→')} {dim(preview + ellipsis)}", end="")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def end_stream() -> None:
|
|
126
|
+
"""Called after all stream events."""
|
|
127
|
+
print() # final newline
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Status after completion ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def print_completion(
|
|
133
|
+
policy: str,
|
|
134
|
+
input_tokens: int,
|
|
135
|
+
output_tokens: int,
|
|
136
|
+
cancelled: bool = False,
|
|
137
|
+
) -> None:
|
|
138
|
+
total = input_tokens + output_tokens
|
|
139
|
+
if cancelled:
|
|
140
|
+
status = red("cancelled")
|
|
141
|
+
else:
|
|
142
|
+
status = green("✓ done")
|
|
143
|
+
|
|
144
|
+
print(
|
|
145
|
+
f"\n{dim(' ')}{status} "
|
|
146
|
+
f"{dim('│')} policy: {dim(policy)} "
|
|
147
|
+
f"{dim('│')} tokens: {dim(str(total))} "
|
|
148
|
+
f"{dim(f'(in: {input_tokens} out: {output_tokens})')}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def print_error(msg: str) -> None:
|
|
153
|
+
print(f"\n{red(' ✗')} {msg}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def print_info(msg: str) -> None:
|
|
157
|
+
print(f"\n{dim(' →')} {msg}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def print_success(msg: str) -> None:
|
|
161
|
+
print(f"\n{green(' ✓')} {msg}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ── Prompt ─────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
def prompt(prefix: str = "> ") -> str:
|
|
167
|
+
"""Read user input. Returns stripped string."""
|
|
168
|
+
try:
|
|
169
|
+
return input(bold(prefix)).strip()
|
|
170
|
+
except (KeyboardInterrupt, EOFError):
|
|
171
|
+
return "exit"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Helpers ────────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
def _format_args(args: dict) -> str:
|
|
177
|
+
if not args:
|
|
178
|
+
return ""
|
|
179
|
+
parts = []
|
|
180
|
+
for k, v in list(args.items())[:2]: # show max 2 args
|
|
181
|
+
val = str(v)[:30]
|
|
182
|
+
parts.append(f"{k}={val!r}")
|
|
183
|
+
if len(args) > 2:
|
|
184
|
+
parts.append("…")
|
|
185
|
+
return ", ".join(parts)
|
axor_cli/main.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
axor CLI — governed agent sessions from the terminal.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
axor claude # interactive REPL
|
|
8
|
+
axor claude "refactor auth module" # single task and exit
|
|
9
|
+
axor claude --policy readonly # with preset policy
|
|
10
|
+
axor claude --limit 100000 # with soft token limit
|
|
11
|
+
axor claude --model claude-opus-4-5 # specific model
|
|
12
|
+
axor --list-adapters # show available adapters
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
# ensure axor-core is importable when running from source
|
|
21
|
+
_here = os.path.dirname(os.path.abspath(__file__))
|
|
22
|
+
for _candidate in [
|
|
23
|
+
os.path.join(_here, "..", "..", "axor-core"),
|
|
24
|
+
os.path.join(_here, "..", "..", "..", "axor-core"),
|
|
25
|
+
]:
|
|
26
|
+
if os.path.isdir(os.path.join(_candidate, "axor_core")):
|
|
27
|
+
sys.path.insert(0, os.path.abspath(_candidate))
|
|
28
|
+
break
|
|
29
|
+
|
|
30
|
+
from axor_cli import display, auth, adapters, streaming
|
|
31
|
+
from axor_cli._version import __version__
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── Built-in REPL commands ─────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
_HELP = """
|
|
37
|
+
Built-in commands:
|
|
38
|
+
/auth Set or update API key (saved to ~/.axor/config.toml)
|
|
39
|
+
/auth --clear Remove saved API key
|
|
40
|
+
/auth --show Show where key is loaded from (never shows the key)
|
|
41
|
+
/cost Token usage for this session
|
|
42
|
+
/policy Last execution policy
|
|
43
|
+
/compact Compress context (reduces token usage)
|
|
44
|
+
/status Session overview
|
|
45
|
+
/model <name> Switch model (adapter must support it)
|
|
46
|
+
/tools Show tools available to current policy
|
|
47
|
+
/help This message
|
|
48
|
+
exit / quit / ^D Exit axor
|
|
49
|
+
""".strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_args() -> argparse.Namespace:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="axor",
|
|
55
|
+
description="Governed agent sessions — powered by axor-core",
|
|
56
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
57
|
+
epilog=__doc__,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"adapter",
|
|
62
|
+
nargs="?",
|
|
63
|
+
choices=adapters.list_adapters(),
|
|
64
|
+
metavar="ADAPTER",
|
|
65
|
+
help=f"Adapter to use: {', '.join(adapters.list_adapters())}",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"task",
|
|
69
|
+
nargs="?",
|
|
70
|
+
help="Single task to run (skips REPL)",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--policy", "-p",
|
|
74
|
+
choices=["readonly", "sandboxed", "standard", "federated"],
|
|
75
|
+
help="Override policy (skips automatic selection)",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument(
|
|
78
|
+
"--limit", "-l",
|
|
79
|
+
type=int,
|
|
80
|
+
metavar="TOKENS",
|
|
81
|
+
help="Soft token limit for budget optimization",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--model", "-m",
|
|
85
|
+
help="Model to use (default: adapter's default)",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--api-key",
|
|
89
|
+
help="API key (not saved — use /auth for persistent keys)",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--no-skills",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Skip loading CLAUDE.md and .claude/skills/",
|
|
95
|
+
)
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--no-plugins",
|
|
98
|
+
action="store_true",
|
|
99
|
+
help="Skip loading .claude/plugins/",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--tools",
|
|
103
|
+
nargs="+",
|
|
104
|
+
default=["read", "write", "bash", "search", "glob"],
|
|
105
|
+
metavar="TOOL",
|
|
106
|
+
help="Tools to enable (default: read write bash search glob)",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--list-adapters",
|
|
110
|
+
action="store_true",
|
|
111
|
+
help="List available adapters and exit",
|
|
112
|
+
)
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
"--version",
|
|
115
|
+
action="version",
|
|
116
|
+
version=f"axor {__version__}",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
return parser.parse_args()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── REPL loop ──────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async def repl(session, adapter: str, args: argparse.Namespace) -> None:
|
|
125
|
+
"""Interactive REPL loop."""
|
|
126
|
+
policy_override = None
|
|
127
|
+
if args.policy:
|
|
128
|
+
from axor_core import presets
|
|
129
|
+
policy_override = presets.get(args.policy)
|
|
130
|
+
|
|
131
|
+
while True:
|
|
132
|
+
try:
|
|
133
|
+
line = display.prompt("> ")
|
|
134
|
+
except (EOFError, KeyboardInterrupt):
|
|
135
|
+
print()
|
|
136
|
+
display.print_info("Bye.")
|
|
137
|
+
break
|
|
138
|
+
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
|
|
142
|
+
if line.lower() in ("exit", "quit", "q"):
|
|
143
|
+
display.print_info("Bye.")
|
|
144
|
+
break
|
|
145
|
+
|
|
146
|
+
# ── /auth ──────────────────────────────────────────────────────────────
|
|
147
|
+
if line.startswith("/auth"):
|
|
148
|
+
parts = line.split()
|
|
149
|
+
if "--clear" in parts:
|
|
150
|
+
removed = auth.clear_from_config(adapter)
|
|
151
|
+
if removed:
|
|
152
|
+
display.print_success(f"Key removed from ~/.axor/config.toml")
|
|
153
|
+
else:
|
|
154
|
+
display.print_info("No key found in config.")
|
|
155
|
+
|
|
156
|
+
elif "--show" in parts:
|
|
157
|
+
env_var = {"claude": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY"}.get(adapter, "")
|
|
158
|
+
sources = []
|
|
159
|
+
if args.api_key:
|
|
160
|
+
sources.append("--api-key flag (this session only)")
|
|
161
|
+
if env_var and os.environ.get(env_var):
|
|
162
|
+
sources.append(f"{env_var} env var")
|
|
163
|
+
from axor_cli.auth import CONFIG_FILE
|
|
164
|
+
if auth.load_from_config(adapter):
|
|
165
|
+
sources.append(f"~/.axor/config.toml")
|
|
166
|
+
if sources:
|
|
167
|
+
display.print_info(f"Key loaded from: {', '.join(sources)}")
|
|
168
|
+
else:
|
|
169
|
+
display.print_info("No key found. Run /auth to set one.")
|
|
170
|
+
|
|
171
|
+
else:
|
|
172
|
+
key = auth.prompt_and_save(adapter)
|
|
173
|
+
if key:
|
|
174
|
+
# rebuild session with new key
|
|
175
|
+
display.print_success("Key set. Rebuilding session...")
|
|
176
|
+
try:
|
|
177
|
+
new_session = adapters.build_session(
|
|
178
|
+
adapter=adapter,
|
|
179
|
+
api_key=key,
|
|
180
|
+
model=args.model,
|
|
181
|
+
tools=tuple(args.tools),
|
|
182
|
+
soft_token_limit=args.limit,
|
|
183
|
+
load_skills=not args.no_skills,
|
|
184
|
+
load_plugins=not args.no_plugins,
|
|
185
|
+
)
|
|
186
|
+
session = new_session
|
|
187
|
+
display.print_success("Session ready.")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
display.print_error(f"Could not rebuild session: {e}")
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
# ── /help ──────────────────────────────────────────────────────────────
|
|
193
|
+
if line in ("/help", "/?"):
|
|
194
|
+
print(f"\n{_HELP}\n")
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
# ── /model ─────────────────────────────────────────────────────────────
|
|
198
|
+
if line.startswith("/model"):
|
|
199
|
+
parts = line.split(maxsplit=1)
|
|
200
|
+
if len(parts) < 2:
|
|
201
|
+
models = adapters.available_models(adapter)
|
|
202
|
+
display.print_info(f"Available models: {', '.join(models)}")
|
|
203
|
+
else:
|
|
204
|
+
display.print_info(f"Model switching requires session restart. "
|
|
205
|
+
f"Restart with: axor {adapter} --model {parts[1]}")
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
# ── Governed slash commands (forwarded to session) ─────────────────────
|
|
209
|
+
if line.startswith("/"):
|
|
210
|
+
result = await session.run(line)
|
|
211
|
+
output = result.output
|
|
212
|
+
if output and output != "[cancelled]":
|
|
213
|
+
cmd_class = result.metadata.get("class", "passthrough")
|
|
214
|
+
if cmd_class == "governance":
|
|
215
|
+
# structured response from envelope/trace — prefix with →
|
|
216
|
+
display.print_info(output)
|
|
217
|
+
else:
|
|
218
|
+
# context commands and passthrough — plain output
|
|
219
|
+
print(f"\n{output}")
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
# ── Task ───────────────────────────────────────────────────────────────
|
|
223
|
+
await streaming.run_task(session, line, policy=policy_override)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Main ───────────────────────────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
async def async_main() -> int:
|
|
229
|
+
args = _parse_args()
|
|
230
|
+
|
|
231
|
+
# --list-adapters
|
|
232
|
+
if args.list_adapters:
|
|
233
|
+
print("\nAvailable adapters:")
|
|
234
|
+
for name in adapters.list_adapters():
|
|
235
|
+
available = adapters.is_available(name)
|
|
236
|
+
status = display.green("installed") if available else display.red("not installed")
|
|
237
|
+
hint = "" if available else f" → {adapters.get_install_hint(name)}"
|
|
238
|
+
print(f" {name:12} {status}{hint}")
|
|
239
|
+
print()
|
|
240
|
+
return 0
|
|
241
|
+
|
|
242
|
+
# adapter required after this point
|
|
243
|
+
if not args.adapter:
|
|
244
|
+
print("Usage: axor <adapter> [task]")
|
|
245
|
+
print(" axor --list-adapters")
|
|
246
|
+
print(f"\nAvailable adapters: {', '.join(adapters.list_adapters())}")
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
adapter = args.adapter
|
|
250
|
+
|
|
251
|
+
# check adapter installed
|
|
252
|
+
if not adapters.is_available(adapter):
|
|
253
|
+
display.print_error(
|
|
254
|
+
f"Adapter '{adapter}' is not installed.\n"
|
|
255
|
+
f" Install with: {adapters.get_install_hint(adapter)}"
|
|
256
|
+
)
|
|
257
|
+
return 1
|
|
258
|
+
|
|
259
|
+
# resolve API key
|
|
260
|
+
api_key = auth.resolve_api_key(adapter, flag_key=args.api_key)
|
|
261
|
+
if not api_key:
|
|
262
|
+
api_key = auth.prompt_and_save(adapter)
|
|
263
|
+
if not api_key:
|
|
264
|
+
display.print_error("No API key. Exiting.")
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
# build session
|
|
268
|
+
try:
|
|
269
|
+
session = adapters.build_session(
|
|
270
|
+
adapter=adapter,
|
|
271
|
+
api_key=api_key,
|
|
272
|
+
model=args.model,
|
|
273
|
+
tools=tuple(args.tools),
|
|
274
|
+
soft_token_limit=args.limit,
|
|
275
|
+
load_skills=not args.no_skills,
|
|
276
|
+
load_plugins=not args.no_plugins,
|
|
277
|
+
)
|
|
278
|
+
except Exception as e:
|
|
279
|
+
display.print_error(f"Could not start session: {e}")
|
|
280
|
+
return 1
|
|
281
|
+
|
|
282
|
+
policy_override = None
|
|
283
|
+
if args.policy:
|
|
284
|
+
from axor_core import presets
|
|
285
|
+
try:
|
|
286
|
+
policy_override = presets.get(args.policy)
|
|
287
|
+
except KeyError as e:
|
|
288
|
+
display.print_error(str(e))
|
|
289
|
+
return 1
|
|
290
|
+
|
|
291
|
+
# single task mode
|
|
292
|
+
if args.task:
|
|
293
|
+
await streaming.run_task(session, args.task, policy=policy_override)
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
# interactive REPL
|
|
297
|
+
model = args.model or adapters.default_model(adapter)
|
|
298
|
+
display.print_header(adapter=adapter, model=model, version=__version__)
|
|
299
|
+
await repl(session, adapter=adapter, args=args)
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def main() -> None:
|
|
304
|
+
"""Entry point registered in pyproject.toml."""
|
|
305
|
+
try:
|
|
306
|
+
code = asyncio.run(async_main())
|
|
307
|
+
sys.exit(code)
|
|
308
|
+
except KeyboardInterrupt:
|
|
309
|
+
print()
|
|
310
|
+
sys.exit(0)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
main()
|
axor_cli/streaming.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Streaming execution driver for axor-cli.
|
|
5
|
+
|
|
6
|
+
Connects GovernedSession to the terminal:
|
|
7
|
+
- shows spinner while waiting for first token
|
|
8
|
+
- prints text chunks as they arrive via set_text_callback()
|
|
9
|
+
- falls back to full output for non-streaming adapters
|
|
10
|
+
- prints completion stats
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from axor_core import GovernedSession
|
|
17
|
+
from axor_core.contracts.policy import ExecutionPolicy
|
|
18
|
+
|
|
19
|
+
from axor_cli import display
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_task(
|
|
23
|
+
session: GovernedSession,
|
|
24
|
+
task: str,
|
|
25
|
+
policy: ExecutionPolicy | None = None,
|
|
26
|
+
) -> dict[str, Any]:
|
|
27
|
+
"""
|
|
28
|
+
Run a task and stream output to terminal.
|
|
29
|
+
|
|
30
|
+
Returns execution summary:
|
|
31
|
+
{policy, input_tokens, output_tokens, cancelled, error}
|
|
32
|
+
"""
|
|
33
|
+
spinner = display.Spinner(prefix=" ")
|
|
34
|
+
spinner.start()
|
|
35
|
+
|
|
36
|
+
summary: dict[str, Any] = {
|
|
37
|
+
"policy": "unknown",
|
|
38
|
+
"input_tokens": 0,
|
|
39
|
+
"output_tokens": 0,
|
|
40
|
+
"cancelled": False,
|
|
41
|
+
"error": None,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
await _stream_run(session, task, policy, spinner, summary)
|
|
46
|
+
except asyncio.CancelledError:
|
|
47
|
+
spinner.stop()
|
|
48
|
+
summary["cancelled"] = True
|
|
49
|
+
display.print_completion(
|
|
50
|
+
policy=summary["policy"],
|
|
51
|
+
input_tokens=summary["input_tokens"],
|
|
52
|
+
output_tokens=summary["output_tokens"],
|
|
53
|
+
cancelled=True,
|
|
54
|
+
)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
spinner.stop()
|
|
57
|
+
display.print_error(str(e))
|
|
58
|
+
summary["error"] = str(e)
|
|
59
|
+
|
|
60
|
+
return summary
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _stream_run(
|
|
64
|
+
session: GovernedSession,
|
|
65
|
+
task: str,
|
|
66
|
+
policy: ExecutionPolicy | None,
|
|
67
|
+
spinner: display.Spinner,
|
|
68
|
+
summary: dict[str, Any],
|
|
69
|
+
) -> None:
|
|
70
|
+
text_received = False
|
|
71
|
+
executor = session._executor
|
|
72
|
+
|
|
73
|
+
# streaming path — adapter exposes set_text_callback()
|
|
74
|
+
if hasattr(executor, "set_text_callback"):
|
|
75
|
+
def on_text(chunk: str) -> None:
|
|
76
|
+
nonlocal text_received
|
|
77
|
+
if not text_received:
|
|
78
|
+
spinner.stop()
|
|
79
|
+
print() # newline between prompt and output
|
|
80
|
+
text_received = True
|
|
81
|
+
display.stream_text(chunk)
|
|
82
|
+
|
|
83
|
+
executor.set_text_callback(on_text)
|
|
84
|
+
|
|
85
|
+
result = await session.run(task, policy=policy)
|
|
86
|
+
|
|
87
|
+
# ensure spinner stopped even on error/empty output
|
|
88
|
+
spinner.stop()
|
|
89
|
+
|
|
90
|
+
if not text_received:
|
|
91
|
+
# non-streaming adapter — print result all at once
|
|
92
|
+
if result.output and result.output != "[cancelled]":
|
|
93
|
+
print()
|
|
94
|
+
print(result.output)
|
|
95
|
+
|
|
96
|
+
display.end_stream()
|
|
97
|
+
|
|
98
|
+
summary["policy"] = result.metadata.get("policy", "unknown")
|
|
99
|
+
summary["input_tokens"] = result.token_usage.input_tokens
|
|
100
|
+
summary["output_tokens"] = result.token_usage.output_tokens
|
|
101
|
+
summary["cancelled"] = result.metadata.get("cancelled", False)
|
|
102
|
+
|
|
103
|
+
display.print_completion(
|
|
104
|
+
policy=summary["policy"],
|
|
105
|
+
input_tokens=summary["input_tokens"],
|
|
106
|
+
output_tokens=summary["output_tokens"],
|
|
107
|
+
cancelled=summary["cancelled"],
|
|
108
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: axor-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI for axor-core governance kernel — governed agent sessions in your terminal
|
|
5
|
+
Project-URL: Bug Tracker, https://github.com/Bucha11/axor-cli/issues
|
|
6
|
+
Project-URL: Changelog, https://github.com/Bucha11/axor-cli/releases
|
|
7
|
+
Project-URL: Repository, https://github.com/Bucha11/axor-cli
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,axor,claude,cli,governance,llm
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: Terminals
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: axor-core>=0.1.0
|
|
21
|
+
Provides-Extra: all
|
|
22
|
+
Requires-Dist: axor-claude>=0.1.0; extra == 'all'
|
|
23
|
+
Requires-Dist: axor-openai>=0.1.0; extra == 'all'
|
|
24
|
+
Provides-Extra: claude
|
|
25
|
+
Requires-Dist: axor-claude>=0.1.0; extra == 'claude'
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
29
|
+
Provides-Extra: openai
|
|
30
|
+
Requires-Dist: axor-openai>=0.1.0; extra == 'openai'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# axor-cli
|
|
34
|
+
|
|
35
|
+
[](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/axor-cli/)
|
|
37
|
+
[](https://pypi.org/project/axor-cli/)
|
|
38
|
+
[](LICENSE)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
**Governed agent sessions in your terminal.**
|
|
42
|
+
|
|
43
|
+
Run Claude (or other LLMs) under axor-core governance — controlled context, explicit tool permissions, token optimization, and full audit trail.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# CLI + Claude adapter
|
|
51
|
+
pip install axor-cli[claude]
|
|
52
|
+
|
|
53
|
+
# or step by step
|
|
54
|
+
pip install axor-cli
|
|
55
|
+
pip install axor-claude
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Quick Start
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# Interactive REPL
|
|
64
|
+
axor claude
|
|
65
|
+
|
|
66
|
+
# Single task and exit
|
|
67
|
+
axor claude "refactor the auth module"
|
|
68
|
+
|
|
69
|
+
# With options
|
|
70
|
+
axor claude --policy readonly "review this PR for security issues"
|
|
71
|
+
axor claude --limit 100000 "migrate the entire codebase to Go"
|
|
72
|
+
axor claude --model claude-opus-4-5 "design the new architecture"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Authentication
|
|
78
|
+
|
|
79
|
+
On first run, axor asks for your API key and saves it to `~/.axor/config.toml` (permissions: 600):
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
$ axor claude
|
|
83
|
+
|
|
84
|
+
No API key found for 'claude'.
|
|
85
|
+
(checked: --api-key flag, ANTHROPIC_API_KEY env var, ~/.axor/config.toml)
|
|
86
|
+
|
|
87
|
+
Anthropic API key (hidden): ****
|
|
88
|
+
Save to ~/.axor/config.toml for future sessions? [Y/n]: y
|
|
89
|
+
✓ Key saved to ~/.axor/config.toml (permissions: 600)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Key priority (highest to lowest):
|
|
93
|
+
|
|
94
|
+
| Source | When used |
|
|
95
|
+
|--------|-----------|
|
|
96
|
+
| `--api-key` flag | One-off override, never saved |
|
|
97
|
+
| `ANTHROPIC_API_KEY` env var | CI/CD, containers |
|
|
98
|
+
| `~/.axor/config.toml` | Persistent, set via `/auth` |
|
|
99
|
+
|
|
100
|
+
Manage saved keys with `/auth` in the REPL:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
> /auth # set or update key (prompts, then saves)
|
|
104
|
+
> /auth --show # show where key is loaded from (never shows the key)
|
|
105
|
+
> /auth --clear # remove saved key
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Interactive REPL
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
$ axor claude
|
|
114
|
+
axor v0.1.0 │ adapter: claude │ model: claude-sonnet-4-5
|
|
115
|
+
Type a task, a /command, or 'exit' to quit.
|
|
116
|
+
|
|
117
|
+
> refactor the auth module to add rate limiting
|
|
118
|
+
↳ read(path='auth.py') → def authenticate(token):…
|
|
119
|
+
↳ write(path='auth.py') → …
|
|
120
|
+
✓ done │ policy: moderate_mutative │ tokens: 1,247 (in: 800 out: 447)
|
|
121
|
+
|
|
122
|
+
> /cost
|
|
123
|
+
→ Tokens spent this session: 1,247
|
|
124
|
+
|
|
125
|
+
> /compact
|
|
126
|
+
→ Context compaction requested — will apply on next execution.
|
|
127
|
+
|
|
128
|
+
> exit
|
|
129
|
+
→ Bye.
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### REPL commands
|
|
133
|
+
|
|
134
|
+
| Command | Class | Description |
|
|
135
|
+
|---------|-------|-------------|
|
|
136
|
+
| `/auth` | built-in | Set or update API key |
|
|
137
|
+
| `/auth --clear` | built-in | Remove saved key |
|
|
138
|
+
| `/auth --show` | built-in | Show key source (never the key itself) |
|
|
139
|
+
| `/model` | built-in | List available models |
|
|
140
|
+
| `/help` | built-in | All commands |
|
|
141
|
+
| `/cost` | governed | Token usage for this session |
|
|
142
|
+
| `/policy` | governed | Last execution policy |
|
|
143
|
+
| `/compact` | governed | Compress context |
|
|
144
|
+
| `/status` | governed | Session overview |
|
|
145
|
+
| `/tools` | governed | Tools available to current policy |
|
|
146
|
+
| `exit` / `quit` / `^D` | — | Exit |
|
|
147
|
+
|
|
148
|
+
Governed commands (`/cost`, `/policy`, etc.) are handled by axor-core — they never reach the executor.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## CLI options
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
axor <adapter> [task] [options]
|
|
156
|
+
|
|
157
|
+
Arguments:
|
|
158
|
+
adapter Adapter: claude, openai
|
|
159
|
+
task Single task — runs and exits (skips REPL)
|
|
160
|
+
|
|
161
|
+
Options:
|
|
162
|
+
-p, --policy Preset: readonly, sandboxed, standard, federated
|
|
163
|
+
-l, --limit Soft token limit (budget optimization signals)
|
|
164
|
+
-m, --model Model override (e.g. claude-opus-4-5)
|
|
165
|
+
--api-key API key for this session (never saved)
|
|
166
|
+
--tools Tools to enable (default: read write bash search glob)
|
|
167
|
+
--no-skills Skip CLAUDE.md and .claude/skills/
|
|
168
|
+
--no-plugins Skip .claude/plugins/
|
|
169
|
+
--list-adapters Show installed adapters and exit
|
|
170
|
+
--version Show version
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Examples
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
# Analysis only — no writes, no bash
|
|
179
|
+
axor claude --policy readonly "find all security issues in auth.py"
|
|
180
|
+
|
|
181
|
+
# Specific tools only
|
|
182
|
+
axor claude --tools read search "find all TODO comments"
|
|
183
|
+
|
|
184
|
+
# Large migration with budget
|
|
185
|
+
axor claude --limit 200000 "rewrite the API layer to use async/await"
|
|
186
|
+
|
|
187
|
+
# Specific model
|
|
188
|
+
axor claude --model claude-opus-4-5 "design the new microservices architecture"
|
|
189
|
+
|
|
190
|
+
# No extension loading (faster startup)
|
|
191
|
+
axor claude --no-skills --no-plugins "quick question"
|
|
192
|
+
|
|
193
|
+
# CI — reads key from env, single task, exits
|
|
194
|
+
ANTHROPIC_API_KEY=sk-ant-... axor claude "run code review"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Adapters
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
axor --list-adapters
|
|
203
|
+
|
|
204
|
+
Available adapters:
|
|
205
|
+
claude installed
|
|
206
|
+
openai not installed → pip install axor-openai
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Each adapter package must expose `make_session(**kwargs) -> GovernedSession`.
|
|
210
|
+
|
|
211
|
+
---
|
|
212
|
+
|
|
213
|
+
## Streaming output
|
|
214
|
+
|
|
215
|
+
When an adapter supports streaming (e.g. `axor-claude`), text is printed to the terminal as it arrives — no waiting for the full response. A spinner shows while Claude is thinking.
|
|
216
|
+
|
|
217
|
+
Non-streaming adapters print the full output when execution completes.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## Config file
|
|
222
|
+
|
|
223
|
+
`~/.axor/config.toml` — auto-created with `chmod 600`:
|
|
224
|
+
|
|
225
|
+
```toml
|
|
226
|
+
[claude]
|
|
227
|
+
api_key = "sk-ant-..."
|
|
228
|
+
|
|
229
|
+
[openai]
|
|
230
|
+
api_key = "sk-..."
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## Repository structure
|
|
236
|
+
|
|
237
|
+
```
|
|
238
|
+
axor-cli/
|
|
239
|
+
├── axor_cli/
|
|
240
|
+
│ ├── main.py CLI entrypoint, REPL loop, argument parsing
|
|
241
|
+
│ ├── auth.py Key management — ~/.axor/config.toml, priority chain
|
|
242
|
+
│ ├── adapters.py Adapter registry, lazy imports, build_session()
|
|
243
|
+
│ ├── display.py Terminal formatting — color, spinner, streaming output
|
|
244
|
+
│ ├── streaming.py Connects GovernedSession to terminal display
|
|
245
|
+
│ └── _version.py
|
|
246
|
+
└── tests/
|
|
247
|
+
├── conftest.py tmp_home fixture, anthropic mock
|
|
248
|
+
└── unit/
|
|
249
|
+
├── test_auth.py 11 tests — config file, permissions, priority
|
|
250
|
+
├── test_adapters.py 8 tests — registry, availability, session build
|
|
251
|
+
├── test_display.py display formatting
|
|
252
|
+
└── test_streaming.py 11 tests — output, callback, error, policy override
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Running tests
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
pytest tests/unit/ # no API key needed, anthropic SDK mocked
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## Requirements
|
|
266
|
+
|
|
267
|
+
- Python 3.11+
|
|
268
|
+
- `axor-core >= 0.1.0`
|
|
269
|
+
- At least one adapter: `axor-claude` or `axor-openai`
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## License
|
|
274
|
+
|
|
275
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
axor_cli/__init__.py,sha256=h1bnQ-G2v-SAcBO-iMJtfTj3EEmT4t7oeueLMyK8NSI,130
|
|
2
|
+
axor_cli/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
axor_cli/adapters.py,sha256=yw17XEGBV8BFXDmtR3E1Nikf7uIXXROjFpz8mHlo4OQ,3624
|
|
4
|
+
axor_cli/auth.py,sha256=KgCkoNHUm9MMooTKVBIjJR7LN1qOP6mXDYUaiDhlIEc,5015
|
|
5
|
+
axor_cli/display.py,sha256=Z_Dk3as7ZmgbusWepLFnv-0BaBrIzPk-dgWLY4SI0jk,6363
|
|
6
|
+
axor_cli/main.py,sha256=V6JvMy_f01EoMYvdC-UbumOpwKAWCf_rGTZQpQAWo60,11621
|
|
7
|
+
axor_cli/streaming.py,sha256=f3Bv7s7Q8DCE-XkK8LjpjvdO0iE2ublUqqojiRgg3Pw,3063
|
|
8
|
+
axor_cli-0.1.0.dist-info/METADATA,sha256=0bP5HfSgVtd2TSiS0FkRqfViWPBU1nMJKst4clUFYOk,7683
|
|
9
|
+
axor_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
axor_cli-0.1.0.dist-info/entry_points.txt,sha256=DjI2w3zZ_HEFctGqpzADUYTcxvYO2CvQfh5emVIjXP8,44
|
|
11
|
+
axor_cli-0.1.0.dist-info/licenses/LICENSE,sha256=u1tea2uwiiU3VXz7AOfI5nBAW5-Oxbd6WmE3f-00qYQ,1074
|
|
12
|
+
axor_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Axor Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|