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 ADDED
@@ -0,0 +1,3 @@
1
+ """axor-cli — governed agent sessions from the terminal."""
2
+ from axor_cli._version import __version__
3
+ __all__ = ["__version__"]
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
+ [![CI](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Bucha11/axor-cli/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/axor-cli)](https://pypi.org/project/axor-cli/)
37
+ [![Python](https://img.shields.io/pypi/pyversions/axor-cli)](https://pypi.org/project/axor-cli/)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ axor = axor_cli.main:main
@@ -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.