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
@@ -0,0 +1,365 @@
1
+ """Session persistence with JSONL format.
2
+
3
+ Maps to: rust/crates/runtime/src/session.rs
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import enum
9
+ import json
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from axion.runtime.usage import TokenUsage
17
+
18
+ SESSION_VERSION = 1
19
+ ROTATE_AFTER_BYTES = 256 * 1024 # 256 KB
20
+ MAX_ROTATED_FILES = 3
21
+
22
+
23
+ class MessageRole(enum.Enum):
24
+ SYSTEM = "system"
25
+ USER = "user"
26
+ ASSISTANT = "assistant"
27
+ TOOL = "tool"
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Content blocks (tagged union via subclasses)
32
+ # ---------------------------------------------------------------------------
33
+
34
+ @dataclass(frozen=True)
35
+ class ContentBlock:
36
+ """Base class for session content blocks."""
37
+
38
+ def to_dict(self) -> dict[str, Any]:
39
+ raise NotImplementedError
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TextBlock(ContentBlock):
44
+ text: str
45
+
46
+ def to_dict(self) -> dict[str, Any]:
47
+ return {"type": "text", "text": self.text}
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ToolUseBlock(ContentBlock):
52
+ id: str
53
+ name: str
54
+ input: str
55
+
56
+ def to_dict(self) -> dict[str, Any]:
57
+ return {"type": "tool_use", "id": self.id, "name": self.name, "input": self.input}
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ImageBlock(ContentBlock):
62
+ """Image content block — stores base64 data for session persistence."""
63
+ media_type: str # e.g. "image/png", "image/jpeg"
64
+ data: str # base64-encoded
65
+
66
+ def to_dict(self) -> dict[str, Any]:
67
+ return {"type": "image", "media_type": self.media_type, "data": self.data}
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ToolResultBlock(ContentBlock):
72
+ tool_use_id: str
73
+ tool_name: str
74
+ output: str
75
+ is_error: bool = False
76
+
77
+ def to_dict(self) -> dict[str, Any]:
78
+ return {
79
+ "type": "tool_result",
80
+ "tool_use_id": self.tool_use_id,
81
+ "tool_name": self.tool_name,
82
+ "output": self.output,
83
+ "is_error": self.is_error,
84
+ }
85
+
86
+
87
+ def content_block_from_dict(data: dict[str, Any]) -> ContentBlock:
88
+ """Deserialize a content block from a dict."""
89
+ block_type = data.get("type", "text")
90
+ if block_type == "text":
91
+ return TextBlock(text=data.get("text", ""))
92
+ if block_type == "tool_use":
93
+ return ToolUseBlock(id=data["id"], name=data["name"], input=data.get("input", ""))
94
+ if block_type == "image":
95
+ return ImageBlock(
96
+ media_type=data.get("media_type", "image/png"),
97
+ data=data.get("data", ""),
98
+ )
99
+ if block_type == "tool_result":
100
+ return ToolResultBlock(
101
+ tool_use_id=data["tool_use_id"],
102
+ tool_name=data.get("tool_name", ""),
103
+ output=data.get("output", ""),
104
+ is_error=data.get("is_error", False),
105
+ )
106
+ return TextBlock(text=str(data))
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Conversation message
111
+ # ---------------------------------------------------------------------------
112
+
113
+ @dataclass
114
+ class ConversationMessage:
115
+ role: MessageRole
116
+ blocks: list[ContentBlock]
117
+ usage: TokenUsage | None = None
118
+
119
+ def to_dict(self) -> dict[str, Any]:
120
+ d: dict[str, Any] = {
121
+ "role": self.role.value,
122
+ "blocks": [b.to_dict() for b in self.blocks],
123
+ }
124
+ if self.usage is not None:
125
+ d["usage"] = {
126
+ "input_tokens": self.usage.input_tokens,
127
+ "output_tokens": self.usage.output_tokens,
128
+ "cache_creation_input_tokens": self.usage.cache_creation_input_tokens,
129
+ "cache_read_input_tokens": self.usage.cache_read_input_tokens,
130
+ }
131
+ return d
132
+
133
+ @classmethod
134
+ def from_dict(cls, data: dict[str, Any]) -> ConversationMessage:
135
+ role = MessageRole(data["role"])
136
+ blocks = [content_block_from_dict(b) for b in data.get("blocks", [])]
137
+ usage_data = data.get("usage")
138
+ usage = None
139
+ if usage_data:
140
+ usage = TokenUsage(
141
+ input_tokens=usage_data.get("input_tokens", 0),
142
+ output_tokens=usage_data.get("output_tokens", 0),
143
+ cache_creation_input_tokens=usage_data.get("cache_creation_input_tokens", 0),
144
+ cache_read_input_tokens=usage_data.get("cache_read_input_tokens", 0),
145
+ )
146
+ return cls(role=role, blocks=blocks, usage=usage)
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Session compaction and fork metadata
151
+ # ---------------------------------------------------------------------------
152
+
153
+ @dataclass
154
+ class SessionCompaction:
155
+ count: int
156
+ removed_message_count: int
157
+ summary: str
158
+
159
+
160
+ @dataclass
161
+ class SessionFork:
162
+ parent_session_id: str
163
+ branch_name: str | None = None
164
+
165
+
166
+ # ---------------------------------------------------------------------------
167
+ # Session errors
168
+ # ---------------------------------------------------------------------------
169
+
170
+ class SessionError(Exception):
171
+ """Base error for session operations."""
172
+
173
+
174
+ class SessionIoError(SessionError):
175
+ pass
176
+
177
+
178
+ class SessionFormatError(SessionError):
179
+ pass
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Session
184
+ # ---------------------------------------------------------------------------
185
+
186
+ def _generate_session_id() -> str:
187
+ return uuid.uuid4().hex[:16]
188
+
189
+
190
+ def _current_time_ms() -> int:
191
+ return int(time.time() * 1000)
192
+
193
+
194
+ @dataclass
195
+ class Session:
196
+ """Persisted conversational state.
197
+
198
+ Maps to: rust/crates/runtime/src/session.rs::Session
199
+ """
200
+
201
+ version: int = SESSION_VERSION
202
+ session_id: str = field(default_factory=_generate_session_id)
203
+ created_at_ms: int = field(default_factory=_current_time_ms)
204
+ updated_at_ms: int = field(default_factory=_current_time_ms)
205
+ messages: list[ConversationMessage] = field(default_factory=list)
206
+ compaction: SessionCompaction | None = None
207
+ fork: SessionFork | None = None
208
+ _persistence_path: Path | None = field(default=None, repr=False)
209
+
210
+ def with_persistence_path(self, path: Path) -> Session:
211
+ self._persistence_path = path
212
+ return self
213
+
214
+ def push_message(self, msg: ConversationMessage) -> None:
215
+ """Append a message and update the timestamp."""
216
+ self.messages.append(msg)
217
+ self.updated_at_ms = _current_time_ms()
218
+
219
+ def push_user_text(self, text: str) -> None:
220
+ """Shorthand: append a user text message."""
221
+ self.push_message(
222
+ ConversationMessage(
223
+ role=MessageRole.USER,
224
+ blocks=[TextBlock(text=text)],
225
+ )
226
+ )
227
+
228
+ def push_user_image(
229
+ self, media_type: str, data: str, text: str = ""
230
+ ) -> None:
231
+ """Append a user message with an image (and optional text)."""
232
+ blocks: list[ContentBlock] = [ImageBlock(media_type=media_type, data=data)]
233
+ if text:
234
+ blocks.append(TextBlock(text=text))
235
+ self.push_message(
236
+ ConversationMessage(role=MessageRole.USER, blocks=blocks)
237
+ )
238
+
239
+ def push_assistant_text(self, text: str, usage: TokenUsage | None = None) -> None:
240
+ """Shorthand: append an assistant text message."""
241
+ self.push_message(
242
+ ConversationMessage(
243
+ role=MessageRole.ASSISTANT,
244
+ blocks=[TextBlock(text=text)],
245
+ usage=usage,
246
+ )
247
+ )
248
+
249
+ def message_count(self) -> int:
250
+ return len(self.messages)
251
+
252
+ # -----------------------------------------------------------------------
253
+ # Persistence (JSONL)
254
+ # -----------------------------------------------------------------------
255
+
256
+ def save(self, path: Path | None = None) -> None:
257
+ """Save session to JSONL file with rotation."""
258
+ target = path or self._persistence_path
259
+ if target is None:
260
+ raise SessionError("No persistence path configured")
261
+
262
+ target.parent.mkdir(parents=True, exist_ok=True)
263
+
264
+ # Rotate if file is too large
265
+ if target.exists() and target.stat().st_size > ROTATE_AFTER_BYTES:
266
+ self._rotate(target)
267
+
268
+ data = self._to_dict()
269
+ line = json.dumps(data, separators=(",", ":"))
270
+
271
+ with open(target, "a", encoding="utf-8") as f:
272
+ f.write(line + "\n")
273
+
274
+ @classmethod
275
+ def load(cls, path: Path) -> Session:
276
+ """Load session from JSONL file (reads last complete entry)."""
277
+ if not path.exists():
278
+ raise SessionIoError(f"Session file not found: {path}")
279
+
280
+ last_entry: dict[str, Any] | None = None
281
+ with open(path, encoding="utf-8") as f:
282
+ for line in f:
283
+ line = line.strip()
284
+ if line:
285
+ try:
286
+ last_entry = json.loads(line)
287
+ except json.JSONDecodeError:
288
+ continue
289
+
290
+ if last_entry is None:
291
+ raise SessionFormatError(f"No valid entries in session file: {path}")
292
+
293
+ return cls._from_dict(last_entry, path)
294
+
295
+ def _to_dict(self) -> dict[str, Any]:
296
+ d: dict[str, Any] = {
297
+ "version": self.version,
298
+ "session_id": self.session_id,
299
+ "created_at_ms": self.created_at_ms,
300
+ "updated_at_ms": self.updated_at_ms,
301
+ "messages": [m.to_dict() for m in self.messages],
302
+ }
303
+ if self.compaction:
304
+ d["compaction"] = {
305
+ "count": self.compaction.count,
306
+ "removed_message_count": self.compaction.removed_message_count,
307
+ "summary": self.compaction.summary,
308
+ }
309
+ if self.fork:
310
+ d["fork"] = {
311
+ "parent_session_id": self.fork.parent_session_id,
312
+ "branch_name": self.fork.branch_name,
313
+ }
314
+ return d
315
+
316
+ @classmethod
317
+ def _from_dict(cls, data: dict[str, Any], path: Path | None = None) -> Session:
318
+ messages = [ConversationMessage.from_dict(m) for m in data.get("messages", [])]
319
+
320
+ compaction = None
321
+ if "compaction" in data and data["compaction"]:
322
+ c = data["compaction"]
323
+ compaction = SessionCompaction(
324
+ count=c["count"],
325
+ removed_message_count=c["removed_message_count"],
326
+ summary=c["summary"],
327
+ )
328
+
329
+ fork = None
330
+ if "fork" in data and data["fork"]:
331
+ f = data["fork"]
332
+ fork = SessionFork(
333
+ parent_session_id=f["parent_session_id"],
334
+ branch_name=f.get("branch_name"),
335
+ )
336
+
337
+ session = cls(
338
+ version=data.get("version", SESSION_VERSION),
339
+ session_id=data.get("session_id", _generate_session_id()),
340
+ created_at_ms=data.get("created_at_ms", _current_time_ms()),
341
+ updated_at_ms=data.get("updated_at_ms", _current_time_ms()),
342
+ messages=messages,
343
+ compaction=compaction,
344
+ fork=fork,
345
+ )
346
+ if path:
347
+ session._persistence_path = path
348
+ return session
349
+
350
+ @staticmethod
351
+ def _rotate(path: Path) -> None:
352
+ """Rotate session files, keeping up to MAX_ROTATED_FILES."""
353
+ for i in range(MAX_ROTATED_FILES - 1, 0, -1):
354
+ src = path.with_suffix(f".{i}.jsonl")
355
+ dst = path.with_suffix(f".{i + 1}.jsonl")
356
+ if src.exists():
357
+ if i + 1 > MAX_ROTATED_FILES:
358
+ src.unlink()
359
+ else:
360
+ src.rename(dst)
361
+
362
+ # Rotate current to .1
363
+ rotated = path.with_suffix(".1.jsonl")
364
+ if path.exists():
365
+ path.rename(rotated)
@@ -0,0 +1,159 @@
1
+ """Session sharing — export a session as a shareable link or file.
2
+
3
+ Sharing modes:
4
+ 1. File export: /share file → creates a .axion-share file that others can import
5
+ 2. JSON export: /share json → outputs a portable JSON blob
6
+ 3. Link (future): /share link → uploads to a server and returns a URL
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import time
14
+ import zlib
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from axion.runtime.session import Session
19
+
20
+
21
+ @dataclass
22
+ class SharedSession:
23
+ """A portable session snapshot for sharing."""
24
+
25
+ session_id: str
26
+ messages_count: int
27
+ created_at_ms: int
28
+ shared_at_ms: int
29
+ shared_by: str
30
+ data: str # Compressed, base64-encoded session JSON
31
+
32
+
33
+ def export_session_for_sharing(
34
+ session: Session,
35
+ shared_by: str = "",
36
+ ) -> SharedSession:
37
+ """Export a session as a shareable snapshot."""
38
+ # Serialize session to JSON
39
+ session_dict = session._to_dict()
40
+ session_json = json.dumps(session_dict, separators=(",", ":"))
41
+
42
+ # Compress and encode
43
+ compressed = zlib.compress(session_json.encode("utf-8"))
44
+ encoded = base64.b64encode(compressed).decode("ascii")
45
+
46
+ return SharedSession(
47
+ session_id=session.session_id,
48
+ messages_count=session.message_count(),
49
+ created_at_ms=session.created_at_ms,
50
+ shared_at_ms=int(time.time() * 1000),
51
+ shared_by=shared_by,
52
+ data=encoded,
53
+ )
54
+
55
+
56
+ def import_shared_session(shared: SharedSession) -> Session:
57
+ """Import a shared session snapshot."""
58
+ # Decode and decompress
59
+ compressed = base64.b64decode(shared.data)
60
+ session_json = zlib.decompress(compressed).decode("utf-8")
61
+ session_dict = json.loads(session_json)
62
+
63
+ return Session._from_dict(session_dict)
64
+
65
+
66
+ def save_share_file(shared: SharedSession, output_path: Path) -> None:
67
+ """Save a shared session to a .axion-share file."""
68
+ data = {
69
+ "version": 1,
70
+ "type": "axion-shared-session",
71
+ "session_id": shared.session_id,
72
+ "messages_count": shared.messages_count,
73
+ "created_at_ms": shared.created_at_ms,
74
+ "shared_at_ms": shared.shared_at_ms,
75
+ "shared_by": shared.shared_by,
76
+ "data": shared.data,
77
+ }
78
+ output_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
79
+
80
+
81
+ def load_share_file(input_path: Path) -> SharedSession:
82
+ """Load a shared session from a .axion-share file."""
83
+ raw = json.loads(input_path.read_text(encoding="utf-8"))
84
+
85
+ if raw.get("type") != "axion-shared-session":
86
+ raise ValueError(f"Not a valid Axion share file: {input_path}")
87
+
88
+ return SharedSession(
89
+ session_id=raw["session_id"],
90
+ messages_count=raw["messages_count"],
91
+ created_at_ms=raw["created_at_ms"],
92
+ shared_at_ms=raw["shared_at_ms"],
93
+ shared_by=raw.get("shared_by", ""),
94
+ data=raw["data"],
95
+ )
96
+
97
+
98
+ def handle_share_command(args: str, session: Session) -> str:
99
+ """Handle /share [file|json|import <path>].
100
+
101
+ Usage:
102
+ /share file — save as .axion-share file
103
+ /share file out.share — save with custom name
104
+ /share json — print as JSON blob
105
+ /share import file.axion-share — import a shared session
106
+ """
107
+ parts = args.strip().split(maxsplit=1)
108
+ action = parts[0].lower() if parts else "file"
109
+ target = parts[1].strip() if len(parts) > 1 else ""
110
+
111
+ if action == "file":
112
+ filename = target or f"session-{session.session_id[:8]}.axion-share"
113
+ output_path = Path.cwd() / filename
114
+ shared = export_session_for_sharing(session)
115
+ save_share_file(shared, output_path)
116
+ return (
117
+ f"Session shared to: {output_path}\n"
118
+ f" Messages: {shared.messages_count}\n"
119
+ f" Size: {output_path.stat().st_size:,} bytes\n\n"
120
+ f"Send this file to a teammate. They can import with:\n"
121
+ f" /share import {filename}"
122
+ )
123
+
124
+ if action == "json":
125
+ shared = export_session_for_sharing(session)
126
+ blob = json.dumps({
127
+ "session_id": shared.session_id,
128
+ "messages": shared.messages_count,
129
+ "data": shared.data[:100] + "...",
130
+ }, indent=2)
131
+ return f"Shared session JSON (truncated):\n{blob}"
132
+
133
+ if action == "import":
134
+ if not target:
135
+ return "Usage: /share import <file.axion-share>"
136
+ import_path = Path(target)
137
+ if not import_path.exists():
138
+ return f"File not found: {target}"
139
+ try:
140
+ shared = load_share_file(import_path)
141
+ imported = import_shared_session(shared)
142
+ # Replace current session
143
+ session.messages = imported.messages
144
+ session.session_id = imported.session_id
145
+ session.created_at_ms = imported.created_at_ms
146
+ return (
147
+ f"Imported session {imported.session_id}\n"
148
+ f" Messages: {imported.message_count()}\n"
149
+ f" Shared by: {shared.shared_by or 'unknown'}"
150
+ )
151
+ except Exception as exc:
152
+ return f"Import failed: {exc}"
153
+
154
+ return (
155
+ "Usage:\n"
156
+ " /share file [name] — export session as shareable file\n"
157
+ " /share json — export as JSON\n"
158
+ " /share import <file> — import a shared session"
159
+ )
@@ -0,0 +1,124 @@
1
+ """Skill loading and execution.
2
+
3
+ Maps to: rust/crates/runtime/src/skills.rs
4
+
5
+ Skills are .md files with optional YAML frontmatter that define reusable
6
+ prompt templates. They can be loaded from several conventional directories
7
+ and executed by replacing {{args}} placeholders with user-supplied arguments.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # Directories searched (relative to cwd) when resolving a skill by name.
21
+ _SKILL_SEARCH_DIRS: list[str] = [
22
+ ".axion/skills", ".claude/skills",
23
+ ".axion/commands", ".claude/commands",
24
+ ".axion/skills",
25
+ ]
26
+
27
+
28
+ @dataclass
29
+ class SkillDefinition:
30
+ """A parsed skill definition."""
31
+
32
+ name: str
33
+ description: str
34
+ content: str
35
+ source_path: Path | None = None
36
+ metadata: dict[str, Any] = field(default_factory=dict)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # YAML frontmatter helpers
41
+ # ---------------------------------------------------------------------------
42
+
43
+ _FRONTMATTER_RE = re.compile(
44
+ r"\A---\s*\n(.*?)\n---\s*\n(.*)",
45
+ re.DOTALL,
46
+ )
47
+
48
+
49
+ def _parse_yaml_frontmatter(text: str) -> tuple[dict[str, Any], str]:
50
+ """Parse simple YAML frontmatter between --- delimiters.
51
+
52
+ Returns (metadata_dict, remaining_content). Uses a lightweight
53
+ key: value parser so we avoid a hard dependency on PyYAML.
54
+ """
55
+ match = _FRONTMATTER_RE.match(text)
56
+ if not match:
57
+ return {}, text
58
+
59
+ yaml_block = match.group(1)
60
+ body = match.group(2)
61
+
62
+ metadata: dict[str, Any] = {}
63
+ for line in yaml_block.splitlines():
64
+ line = line.strip()
65
+ if not line or line.startswith("#"):
66
+ continue
67
+ if ":" in line:
68
+ key, _, value = line.partition(":")
69
+ metadata[key.strip()] = value.strip()
70
+
71
+ return metadata, body
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Public API
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ def load_skill(path: Path) -> SkillDefinition:
80
+ """Read a .md skill file, parse YAML frontmatter, and return a SkillDefinition."""
81
+ text = path.read_text(encoding="utf-8")
82
+ metadata, content = _parse_yaml_frontmatter(text)
83
+
84
+ name = metadata.get("name", path.stem)
85
+ description = metadata.get("description", "")
86
+
87
+ return SkillDefinition(
88
+ name=name,
89
+ description=description,
90
+ content=content.strip(),
91
+ source_path=path,
92
+ metadata=metadata,
93
+ )
94
+
95
+
96
+ def resolve_skill(name: str, cwd: Path) -> Path | None:
97
+ """Search conventional directories for a skill file matching *name*.
98
+
99
+ Searches .claude/skills/, .claude/commands/, .axion/skills/ under *cwd*.
100
+ Returns the first matching .md file, or ``None``.
101
+ """
102
+ for search_dir in _SKILL_SEARCH_DIRS:
103
+ base = cwd / search_dir
104
+
105
+ # Try exact name with .md extension
106
+ candidate = base / f"{name}.md"
107
+ if candidate.is_file():
108
+ return candidate
109
+
110
+ # Try name as-is (may already include extension)
111
+ candidate = base / name
112
+ if candidate.is_file():
113
+ return candidate
114
+
115
+ return None
116
+
117
+
118
+ def execute_skill(skill: SkillDefinition, user_args: str) -> str:
119
+ """Execute a skill by replacing ``{{args}}`` with *user_args*.
120
+
121
+ Returns the skill content with placeholders substituted.
122
+ """
123
+ result = skill.content.replace("{{args}}", user_args)
124
+ return result