axion-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
|
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
|