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
File without changes
File without changes
@@ -0,0 +1,51 @@
1
+ """Agent slash command handler.
2
+
3
+ Maps to: rust/crates/commands/src/lib.rs (handle_agents_slash_command)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass
13
+ class AgentSummary:
14
+ name: str
15
+ description: str
16
+ path: str
17
+
18
+
19
+ def handle_agents_command(args: str, cwd: Path | None = None) -> str:
20
+ """Handle /agents [list] command."""
21
+ actual_cwd = cwd or Path.cwd()
22
+
23
+ agents = discover_agents(actual_cwd)
24
+ if not agents:
25
+ return "No agents found."
26
+
27
+ lines = ["Available agents:", ""]
28
+ for agent in agents:
29
+ lines.append(f" {agent.name} — {agent.description}")
30
+ lines.append(f" Path: {agent.path}")
31
+ return "\n".join(lines)
32
+
33
+
34
+ def discover_agents(cwd: Path) -> list[AgentSummary]:
35
+ """Discover agent definitions in the project."""
36
+ agents: list[AgentSummary] = []
37
+
38
+ # Check .axion/agents/ first, .claude/agents/ for backwards compat
39
+ agents_dir = cwd / ".axion" / "agents"
40
+ if not agents_dir.exists():
41
+ agents_dir = cwd / ".claude" / "agents"
42
+ if agents_dir.exists():
43
+ for f in agents_dir.iterdir():
44
+ if f.suffix == ".md":
45
+ agents.append(AgentSummary(
46
+ name=f.stem,
47
+ description=f"Agent defined in {f.name}",
48
+ path=str(f),
49
+ ))
50
+
51
+ return agents
@@ -0,0 +1,367 @@
1
+ """Built-in slash command handlers: /commit, /undo, /review, /test, /init-project.
2
+
3
+ These are high-value features that differentiate Axion from other tools.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # /commit — Auto-commit with AI-generated message
13
+ # ---------------------------------------------------------------------------
14
+
15
+ def handle_commit_command(args: str, cwd: Path | None = None) -> str:
16
+ """Stage changes and commit with an AI-generated message.
17
+
18
+ Usage:
19
+ /commit — auto-generate commit message from diff
20
+ /commit fix auth bug — use provided message
21
+ """
22
+ actual_cwd = str(cwd or Path.cwd())
23
+
24
+ # Check for uncommitted changes
25
+ status = _run_git(["status", "--porcelain"], actual_cwd)
26
+ if not status.strip():
27
+ return "Nothing to commit — working tree is clean."
28
+
29
+ # Stage all changes
30
+ _run_git(["add", "-A"], actual_cwd)
31
+
32
+ # Get diff for message generation
33
+ diff = _run_git(["diff", "--cached", "--stat"], actual_cwd)
34
+
35
+ if args.strip():
36
+ # User provided a message
37
+ message = args.strip()
38
+ else:
39
+ # Generate message from the diff
40
+ diff_detail = _run_git(["diff", "--cached"], actual_cwd)
41
+ message = _generate_commit_message(diff_detail[:2000])
42
+
43
+ # Commit
44
+ result = _run_git(["commit", "-m", message], actual_cwd)
45
+ if "nothing to commit" in result.lower():
46
+ return "Nothing to commit."
47
+
48
+ return f"Committed:\n {message}\n\n{diff.strip()}"
49
+
50
+
51
+ def _generate_commit_message(diff: str) -> str:
52
+ """Generate a commit message from a diff (heuristic, no AI call)."""
53
+ lines = diff.strip().splitlines()
54
+
55
+ # Count additions/deletions
56
+ additions = sum(1 for ln in lines if ln.startswith("+") and not ln.startswith("+++"))
57
+ deletions = sum(1 for ln in lines if ln.startswith("-") and not ln.startswith("---"))
58
+
59
+ # Get changed files
60
+ files = set()
61
+ for line in lines:
62
+ if line.startswith("diff --git"):
63
+ parts = line.split()
64
+ if len(parts) >= 4:
65
+ files.add(parts[3].lstrip("b/"))
66
+ elif line.startswith("--- a/") or line.startswith("+++ b/"):
67
+ path = line[6:]
68
+ if path != "/dev/null":
69
+ files.add(path)
70
+
71
+ file_list = ", ".join(sorted(files)[:3])
72
+ if len(files) > 3:
73
+ file_list += f" (+{len(files) - 3} more)"
74
+
75
+ if additions > 0 and deletions == 0:
76
+ action = "Add"
77
+ elif deletions > 0 and additions == 0:
78
+ action = "Remove"
79
+ else:
80
+ action = "Update"
81
+
82
+ return f"{action} {file_list} ({additions}+ {deletions}-)"
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # /undo — Revert last AI change via git
87
+ # ---------------------------------------------------------------------------
88
+
89
+ def handle_undo_command(args: str, cwd: Path | None = None) -> str:
90
+ """Undo the last change by reverting to previous git state.
91
+
92
+ Usage:
93
+ /undo — undo last commit (soft reset)
94
+ /undo hard — undo and discard all changes
95
+ /undo file.py — restore a specific file
96
+ """
97
+ actual_cwd = str(cwd or Path.cwd())
98
+ target = args.strip()
99
+
100
+ if not target or target == "last":
101
+ # Soft reset — undoes the commit but keeps changes staged
102
+ result = _run_git(["reset", "--soft", "HEAD~1"], actual_cwd)
103
+ if "fatal" in result.lower():
104
+ return f"Undo failed: {result}"
105
+ return "Undone — last commit reverted (changes kept staged). Use /undo hard to discard."
106
+
107
+ if target == "hard":
108
+ # Hard reset — discard everything
109
+ result = _run_git(["reset", "--hard", "HEAD~1"], actual_cwd)
110
+ if "fatal" in result.lower():
111
+ return f"Undo failed: {result}"
112
+ return "Undone — last commit and all changes discarded."
113
+
114
+ # Restore a specific file
115
+ file_path = target
116
+ result = _run_git(["checkout", "HEAD", "--", file_path], actual_cwd)
117
+ if "error" in result.lower() or "fatal" in result.lower():
118
+ # Try with restore instead
119
+ result = _run_git(["restore", file_path], actual_cwd)
120
+ if "error" in result.lower() or "fatal" in result.lower():
121
+ return f"Could not restore {file_path}: {result}"
122
+ return f"Restored {file_path} to last committed version."
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # /review — Code review mode
127
+ # ---------------------------------------------------------------------------
128
+
129
+ def handle_review_command(args: str, cwd: Path | None = None) -> str:
130
+ """Analyze recent changes for bugs, issues, and improvements.
131
+
132
+ Returns a structured review prompt for the AI to process.
133
+
134
+ Usage:
135
+ /review — review uncommitted changes
136
+ /review HEAD~3 — review last 3 commits
137
+ /review file.py — review a specific file
138
+ """
139
+ actual_cwd = str(cwd or Path.cwd())
140
+ target = args.strip()
141
+
142
+ if not target:
143
+ # Review uncommitted changes
144
+ diff = _run_git(["diff"], actual_cwd)
145
+ staged = _run_git(["diff", "--cached"], actual_cwd)
146
+ combined = ""
147
+ if staged.strip():
148
+ combined += f"=== Staged Changes ===\n{staged}\n\n"
149
+ if diff.strip():
150
+ combined += f"=== Unstaged Changes ===\n{diff}\n\n"
151
+ if not combined.strip():
152
+ return "No changes to review. Make some changes first."
153
+
154
+ # Truncate if too long
155
+ if len(combined) > 5000:
156
+ combined = combined[:5000] + "\n... (truncated)"
157
+
158
+ return (
159
+ "REVIEW_MODE: Please review these code changes for:\n"
160
+ "1. Bugs or logic errors\n"
161
+ "2. Security vulnerabilities\n"
162
+ "3. Performance issues\n"
163
+ "4. Missing error handling\n"
164
+ "5. Code style improvements\n\n"
165
+ f"{combined}\n\n"
166
+ "Provide a structured review with severity ratings (critical/warning/info)."
167
+ )
168
+
169
+ if target.startswith("HEAD"):
170
+ # Review specific commits
171
+ diff = _run_git(["diff", f"{target}..HEAD"], actual_cwd)
172
+ if not diff.strip():
173
+ return f"No changes found in range {target}..HEAD"
174
+ if len(diff) > 5000:
175
+ diff = diff[:5000] + "\n... (truncated)"
176
+ return (
177
+ f"REVIEW_MODE: Review the changes from {target} to HEAD:\n\n"
178
+ f"{diff}\n\n"
179
+ "Check for bugs, security issues, and improvements."
180
+ )
181
+
182
+ # Review a specific file
183
+ file_path = Path(actual_cwd) / target
184
+ if not file_path.exists():
185
+ return f"File not found: {target}"
186
+
187
+ content = file_path.read_text(encoding="utf-8", errors="replace")
188
+ if len(content) > 5000:
189
+ content = content[:5000] + "\n... (truncated)"
190
+
191
+ return (
192
+ f"REVIEW_MODE: Review this file for bugs, security issues, and improvements:\n\n"
193
+ f"File: {target}\n"
194
+ f"```\n{content}\n```\n\n"
195
+ "Provide a structured review with line numbers and severity ratings."
196
+ )
197
+
198
+
199
+ # ---------------------------------------------------------------------------
200
+ # /test — Generate tests for a file
201
+ # ---------------------------------------------------------------------------
202
+
203
+ def handle_test_command(args: str, cwd: Path | None = None) -> str:
204
+ """Generate tests for a file or module.
205
+
206
+ Usage:
207
+ /test src/auth.py — generate tests for auth.py
208
+ /test src/api/ — generate tests for all files in api/
209
+ /test src/auth.py --pytest — use pytest framework
210
+ """
211
+ actual_cwd = cwd or Path.cwd()
212
+ target = args.strip()
213
+
214
+ if not target:
215
+ return (
216
+ "Usage: /test <file_or_dir>\n"
217
+ " /test src/auth.py — generate tests for a file\n"
218
+ " /test src/api/ — generate tests for a directory\n"
219
+ " /test src/auth.py pytest — use pytest framework"
220
+ )
221
+
222
+ # Parse framework preference
223
+ framework = "pytest" # default
224
+ parts = target.split()
225
+ if len(parts) > 1 and parts[-1] in ("pytest", "unittest", "jest", "mocha", "vitest"):
226
+ framework = parts[-1]
227
+ target = " ".join(parts[:-1])
228
+
229
+ file_path = actual_cwd / target
230
+
231
+ if file_path.is_file():
232
+ content = file_path.read_text(encoding="utf-8", errors="replace")
233
+ if len(content) > 4000:
234
+ content = content[:4000] + "\n... (truncated)"
235
+
236
+ return (
237
+ f"TEST_MODE: Generate comprehensive {framework} tests for this file:\n\n"
238
+ f"File: {target}\n"
239
+ f"```\n{content}\n```\n\n"
240
+ f"Requirements:\n"
241
+ f"- Use {framework} framework\n"
242
+ f"- Test all public functions/methods\n"
243
+ f"- Include edge cases and error cases\n"
244
+ f"- Use descriptive test names\n"
245
+ f"- Write the test file to the appropriate tests/ directory"
246
+ )
247
+
248
+ if file_path.is_dir():
249
+ files = list(file_path.rglob("*.py"))[:10]
250
+ if not files:
251
+ files = list(file_path.rglob("*.ts"))[:10]
252
+ if not files:
253
+ files = list(file_path.rglob("*.js"))[:10]
254
+
255
+ file_list = "\n".join(f" - {f.relative_to(actual_cwd)}" for f in files)
256
+ return (
257
+ f"TEST_MODE: Generate {framework} tests for these files:\n\n"
258
+ f"{file_list}\n\n"
259
+ f"Read each file, then generate comprehensive tests."
260
+ )
261
+
262
+ return f"Not found: {target}"
263
+
264
+
265
+ # ---------------------------------------------------------------------------
266
+ # /init-project — Project template scaffolding
267
+ # ---------------------------------------------------------------------------
268
+
269
+ TEMPLATES = {
270
+ "react": {
271
+ "name": "React + TypeScript",
272
+ "questions": "What features? (auth/database/tailwind/none)",
273
+ "description": "React app with TypeScript, Vite, and optional features",
274
+ },
275
+ "nextjs": {
276
+ "name": "Next.js Full-Stack",
277
+ "questions": "Database? (postgres/sqlite/none), Auth? (y/n)",
278
+ "description": "Next.js with App Router, TypeScript, Tailwind",
279
+ },
280
+ "django": {
281
+ "name": "Django REST API",
282
+ "questions": "Database? (postgres/sqlite), Auth type? (jwt/session/oauth)",
283
+ "description": "Django + DRF with PostgreSQL, JWT auth, Docker",
284
+ },
285
+ "fastapi": {
286
+ "name": "FastAPI Backend",
287
+ "questions": "Database? (postgres/sqlite), Add Docker? (y/n)",
288
+ "description": "FastAPI with Pydantic, SQLAlchemy, async",
289
+ },
290
+ "express": {
291
+ "name": "Express.js API",
292
+ "questions": "TypeScript? (y/n), Database? (postgres/mongodb/none)",
293
+ "description": "Node.js Express with optional TypeScript and database",
294
+ },
295
+ "cli": {
296
+ "name": "Python CLI Tool",
297
+ "questions": "Framework? (click/typer/argparse)",
298
+ "description": "Python CLI with Click, tests, and packaging",
299
+ },
300
+ "flask": {
301
+ "name": "Flask Web App",
302
+ "questions": "Database? (postgres/sqlite), Add auth? (y/n)",
303
+ "description": "Flask with Blueprints, SQLAlchemy, templates",
304
+ },
305
+ "chrome-ext": {
306
+ "name": "Chrome Extension",
307
+ "questions": "Manifest version? (v3), Need popup? (y/n)",
308
+ "description": "Chrome extension with manifest v3",
309
+ },
310
+ }
311
+
312
+
313
+ def handle_init_project_command(args: str, cwd: Path | None = None) -> str:
314
+ """Scaffold a new project from a template with AI guidance.
315
+
316
+ Usage:
317
+ /init-project — show available templates
318
+ /init-project react — create a React project
319
+ /init-project fastapi — create a FastAPI backend
320
+ """
321
+ template = args.strip().lower()
322
+
323
+ if not template:
324
+ lines = ["Available project templates:", ""]
325
+ for key, info in TEMPLATES.items():
326
+ lines.append(f" /init-project {key:12} — {info['name']}: {info['description']}")
327
+ lines.append("")
328
+ lines.append("Example: /init-project react")
329
+ return "\n".join(lines)
330
+
331
+ if template not in TEMPLATES:
332
+ available = ", ".join(TEMPLATES.keys())
333
+ return f"Unknown template: {template}\nAvailable: {available}"
334
+
335
+ info = TEMPLATES[template]
336
+ return (
337
+ f"INIT_PROJECT_MODE: Create a new {info['name']} project.\n\n"
338
+ f"Template: {template}\n"
339
+ f"Description: {info['description']}\n\n"
340
+ f"Ask the user these questions first:\n"
341
+ f" - Project name?\n"
342
+ f" - {info['questions']}\n\n"
343
+ f"Then create the complete project structure with working code:\n"
344
+ f"1. Create all necessary config files\n"
345
+ f"2. Create source files with real implementation (not just boilerplate)\n"
346
+ f"3. Create a README.md with setup instructions\n"
347
+ f"4. Create a .gitignore\n"
348
+ f"5. If database needed, create initial schema/migrations\n"
349
+ f"6. If auth needed, create working auth implementation\n"
350
+ )
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Git helper
355
+ # ---------------------------------------------------------------------------
356
+
357
+ def _run_git(args: list[str], cwd: str) -> str:
358
+ """Run a git command and return output."""
359
+ try:
360
+ result = subprocess.run(
361
+ ["git"] + args,
362
+ capture_output=True, text=True, cwd=cwd, timeout=15,
363
+ encoding="utf-8", errors="replace",
364
+ )
365
+ return result.stdout + result.stderr
366
+ except (subprocess.SubprocessError, FileNotFoundError):
367
+ return "git command failed"
@@ -0,0 +1,59 @@
1
+ """MCP slash command handler.
2
+
3
+ Maps to: rust/crates/commands/src/lib.rs (handle_mcp_slash_command)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from axion.runtime.mcp.tool_bridge import McpToolRegistry
9
+
10
+
11
+ def handle_mcp_command(args: str, registry: McpToolRegistry | None = None) -> str:
12
+ """Handle /mcp [list|show <server>|help] commands."""
13
+ parts = args.strip().split(maxsplit=1)
14
+ action = parts[0].lower() if parts else "list"
15
+ target = parts[1].strip() if len(parts) > 1 else ""
16
+
17
+ reg = registry or McpToolRegistry()
18
+
19
+ if action in ("list", ""):
20
+ servers = reg.all_servers()
21
+ if not servers:
22
+ return "No MCP servers connected."
23
+ lines = ["Connected MCP servers:", ""]
24
+ for server in servers:
25
+ tool_count = len(server.tools)
26
+ lines.append(
27
+ f" {server.server_name} [{server.status.value}] "
28
+ f"— {tool_count} tool(s)"
29
+ )
30
+ if server.error_message:
31
+ lines.append(f" Error: {server.error_message}")
32
+ return "\n".join(lines)
33
+
34
+ if action == "show":
35
+ if not target:
36
+ return "Usage: /mcp show <server_name>"
37
+ server = reg.get_server(target)
38
+ if server is None:
39
+ return f"Server not found: {target}"
40
+ lines = [f"MCP Server: {server.server_name}", f"Status: {server.status.value}"]
41
+ if server.tools:
42
+ lines.append(f"\nTools ({len(server.tools)}):")
43
+ for tool in server.tools:
44
+ lines.append(f" {tool.name} — {tool.description}")
45
+ if server.resources:
46
+ lines.append(f"\nResources ({len(server.resources)}):")
47
+ for res in server.resources:
48
+ lines.append(f" {res.name} ({res.uri})")
49
+ return "\n".join(lines)
50
+
51
+ if action == "help":
52
+ return (
53
+ "MCP (Model Context Protocol) commands:\n"
54
+ " /mcp list — List connected servers\n"
55
+ " /mcp show <name> — Show server details and tools\n"
56
+ " /mcp help — Show this help"
57
+ )
58
+
59
+ return f"Unknown MCP action: {action}. Use: list, show, help"
@@ -0,0 +1,75 @@
1
+ """Handler for the /models slash command.
2
+
3
+ Lists locally available Ollama models when an Ollama server is running.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+
10
+ from axion.api.ollama import OllamaClient
11
+
12
+
13
+ def handle_models_command(args: str = "") -> str:
14
+ """Handle /models -- list available Ollama models.
15
+
16
+ Runs the async listing synchronously so the command handler can be
17
+ invoked from a synchronous slash-command dispatch path.
18
+ """
19
+ try:
20
+ loop = asyncio.get_running_loop()
21
+ except RuntimeError:
22
+ loop = None
23
+
24
+ if loop and loop.is_running():
25
+ # Already inside an async context; create a task via a new thread
26
+ import concurrent.futures
27
+
28
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
29
+ result = pool.submit(_list_models_sync).result(timeout=10)
30
+ return result
31
+
32
+ return asyncio.run(_list_models_async())
33
+
34
+
35
+ def _list_models_sync() -> str:
36
+ return asyncio.run(_list_models_async())
37
+
38
+
39
+ async def _list_models_async() -> str:
40
+ client = OllamaClient.from_env()
41
+
42
+ if not await client.is_available():
43
+ await client.close()
44
+ return (
45
+ "Ollama is not running.\n"
46
+ "Start it with: ollama serve\n"
47
+ f"Expected at: {client.base_url}"
48
+ )
49
+
50
+ try:
51
+ models = await client.list_models()
52
+ finally:
53
+ await client.close()
54
+
55
+ if not models:
56
+ return "Ollama is running but no models are installed.\nPull one with: ollama pull llama3.1"
57
+
58
+ lines = [f"Available Ollama models ({len(models)}):", ""]
59
+ for m in models:
60
+ size_mb = m.size / (1024 * 1024) if m.size else 0
61
+ family = m.details.get("family", "")
62
+ params = m.details.get("parameter_size", "")
63
+ detail_parts: list[str] = []
64
+ if params:
65
+ detail_parts.append(params)
66
+ if family:
67
+ detail_parts.append(family)
68
+ if size_mb > 0:
69
+ detail_parts.append(f"{size_mb:.0f} MB")
70
+ detail = f" ({', '.join(detail_parts)})" if detail_parts else ""
71
+ lines.append(f" {m.name}{detail}")
72
+
73
+ lines.append("")
74
+ lines.append('Use a model with: /model <name> or --model "<name>"')
75
+ return "\n".join(lines)
@@ -0,0 +1,55 @@
1
+ """Plugin slash command handler.
2
+
3
+ Maps to: rust/crates/commands/src/lib.rs (handle_plugins_slash_command)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ from axion.plugins.manager import PluginManager
11
+
12
+
13
+ def handle_plugins_command(args: str, manager: PluginManager) -> str:
14
+ """Handle /plugins [list|install|enable|disable|uninstall] commands."""
15
+ parts = args.strip().split(maxsplit=1)
16
+ action = parts[0].lower() if parts else "list"
17
+ target = parts[1].strip() if len(parts) > 1 else ""
18
+
19
+ if action == "list":
20
+ summaries = manager.registry.summaries()
21
+ if not summaries:
22
+ return "No plugins installed."
23
+ lines = ["Installed plugins:", ""]
24
+ for s in summaries:
25
+ status = "enabled" if s.enabled else "disabled"
26
+ lines.append(
27
+ f" {s.name} v{s.version} [{status}] "
28
+ f"— {s.description} ({s.tool_count} tools, {s.command_count} commands)"
29
+ )
30
+ return "\n".join(lines)
31
+
32
+ if action == "install":
33
+ if not target:
34
+ return "Usage: /plugins install <path>"
35
+ summary = manager.install(Path(target))
36
+ if summary:
37
+ return f"Installed plugin: {summary.name} v{summary.version}"
38
+ return f"Failed to install plugin from: {target}"
39
+
40
+ if action == "enable":
41
+ if manager.enable(target):
42
+ return f"Enabled plugin: {target}"
43
+ return f"Plugin not found: {target}"
44
+
45
+ if action == "disable":
46
+ if manager.disable(target):
47
+ return f"Disabled plugin: {target}"
48
+ return f"Plugin not found: {target}"
49
+
50
+ if action == "uninstall":
51
+ if manager.uninstall(target):
52
+ return f"Uninstalled plugin: {target}"
53
+ return f"Plugin not found: {target}"
54
+
55
+ return f"Unknown plugin action: {action}. Use: list, install, enable, disable, uninstall"
@@ -0,0 +1,61 @@
1
+ """Skill slash command handler.
2
+
3
+ Maps to: rust/crates/commands/src/lib.rs (handle_skills_slash_command)
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+
12
+ @dataclass
13
+ class SkillSummary:
14
+ name: str
15
+ description: str
16
+ path: str
17
+
18
+
19
+ def handle_skills_command(args: str, cwd: Path | None = None) -> str:
20
+ """Handle /skills [list] command."""
21
+ actual_cwd = cwd or Path.cwd()
22
+
23
+ skills = discover_skills(actual_cwd)
24
+ if not skills:
25
+ return "No skills found."
26
+
27
+ lines = ["Available skills:", ""]
28
+ for skill in skills:
29
+ lines.append(f" /{skill.name} — {skill.description}")
30
+ return "\n".join(lines)
31
+
32
+
33
+ def discover_skills(cwd: Path) -> list[SkillSummary]:
34
+ """Discover skill definitions in the project."""
35
+ skills: list[SkillSummary] = []
36
+
37
+ # Check .axion/skills/ first, .claude/skills/ for backwards compat
38
+ skills_dir = cwd / ".axion" / "skills"
39
+ if not skills_dir.exists():
40
+ skills_dir = cwd / ".claude" / "skills"
41
+ if skills_dir.exists():
42
+ for f in skills_dir.iterdir():
43
+ if f.suffix == ".md":
44
+ skills.append(SkillSummary(
45
+ name=f.stem,
46
+ description=f"Skill defined in {f.name}",
47
+ path=str(f),
48
+ ))
49
+
50
+ # Check .claude/commands/ (legacy)
51
+ commands_dir = cwd / ".claude" / "commands"
52
+ if commands_dir.exists():
53
+ for f in commands_dir.iterdir():
54
+ if f.suffix == ".md":
55
+ skills.append(SkillSummary(
56
+ name=f.stem,
57
+ description=f"Skill from commands/{f.name}",
58
+ path=str(f),
59
+ ))
60
+
61
+ return skills