loadout 0.3.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.
- loadout/__init__.py +66 -0
- loadout/_prompts.py +44 -0
- loadout/_transforms.py +77 -0
- loadout/_version.py +1 -0
- loadout/adapters/__init__.py +13 -0
- loadout/adapters/_base.py +144 -0
- loadout/adapters/_protocol.py +61 -0
- loadout/adapters/claude.py +44 -0
- loadout/adapters/cursor.py +60 -0
- loadout/adapters/opencode.py +36 -0
- loadout/callbacks.py +51 -0
- loadout/discovery.py +160 -0
- loadout/exceptions.py +41 -0
- loadout/installer.py +163 -0
- loadout/models.py +104 -0
- loadout/registry.py +61 -0
- loadout-0.3.0.dist-info/METADATA +364 -0
- loadout-0.3.0.dist-info/RECORD +20 -0
- loadout-0.3.0.dist-info/WHEEL +4 -0
- loadout-0.3.0.dist-info/licenses/LICENSE +21 -0
loadout/__init__.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""loadout - Install artifacts into coding agents."""
|
|
2
|
+
|
|
3
|
+
from loadout._version import __version__
|
|
4
|
+
from loadout.adapters import AgentAdapter, ClaudeCodeAdapter, CursorAdapter, OpenCodeAdapter
|
|
5
|
+
from loadout.callbacks import LoadoutCallbacks, NoOpCallbacks
|
|
6
|
+
from loadout.discovery import detect_agents, discover_artifacts
|
|
7
|
+
from loadout.exceptions import (
|
|
8
|
+
AdapterAlreadyRegisteredError,
|
|
9
|
+
AdapterNotFoundError,
|
|
10
|
+
ArtifactNotFoundError,
|
|
11
|
+
InstallError,
|
|
12
|
+
LoadoutError,
|
|
13
|
+
ManifestError,
|
|
14
|
+
TransformError,
|
|
15
|
+
)
|
|
16
|
+
from loadout.installer import install, install_all, install_interactive
|
|
17
|
+
from loadout.models import (
|
|
18
|
+
Artifact,
|
|
19
|
+
ArtifactFrontmatter,
|
|
20
|
+
ArtifactType,
|
|
21
|
+
DetectedAgent,
|
|
22
|
+
InstallResult,
|
|
23
|
+
InstallStatus,
|
|
24
|
+
InstallSummary,
|
|
25
|
+
Manifest,
|
|
26
|
+
)
|
|
27
|
+
from loadout.registry import AdapterRegistry, get_default_registry
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"__version__",
|
|
31
|
+
# Adapters
|
|
32
|
+
"AgentAdapter",
|
|
33
|
+
"ClaudeCodeAdapter",
|
|
34
|
+
"CursorAdapter",
|
|
35
|
+
"OpenCodeAdapter",
|
|
36
|
+
# Callbacks
|
|
37
|
+
"LoadoutCallbacks",
|
|
38
|
+
"NoOpCallbacks",
|
|
39
|
+
# Discovery
|
|
40
|
+
"detect_agents",
|
|
41
|
+
"discover_artifacts",
|
|
42
|
+
# Exceptions
|
|
43
|
+
"AdapterAlreadyRegisteredError",
|
|
44
|
+
"AdapterNotFoundError",
|
|
45
|
+
"ArtifactNotFoundError",
|
|
46
|
+
"InstallError",
|
|
47
|
+
"LoadoutError",
|
|
48
|
+
"ManifestError",
|
|
49
|
+
"TransformError",
|
|
50
|
+
# Installer
|
|
51
|
+
"install",
|
|
52
|
+
"install_all",
|
|
53
|
+
"install_interactive",
|
|
54
|
+
# Models
|
|
55
|
+
"Artifact",
|
|
56
|
+
"ArtifactFrontmatter",
|
|
57
|
+
"ArtifactType",
|
|
58
|
+
"DetectedAgent",
|
|
59
|
+
"InstallResult",
|
|
60
|
+
"InstallStatus",
|
|
61
|
+
"InstallSummary",
|
|
62
|
+
"Manifest",
|
|
63
|
+
# Registry
|
|
64
|
+
"AdapterRegistry",
|
|
65
|
+
"get_default_registry",
|
|
66
|
+
]
|
loadout/_prompts.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Built-in interactive prompts (requires questionary)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from loadout.models import DetectedAgent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def prompt_agent_selection(agents: list[DetectedAgent]) -> list[DetectedAgent]:
|
|
9
|
+
"""Present a checkbox prompt for agent selection.
|
|
10
|
+
|
|
11
|
+
Requires the `questionary` package (install with `loadout[interactive]`).
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
agents: Available agents to choose from.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
List of selected agents.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ImportError: If questionary is not installed.
|
|
21
|
+
"""
|
|
22
|
+
try:
|
|
23
|
+
import questionary
|
|
24
|
+
except ImportError:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"questionary is required for interactive prompts. "
|
|
27
|
+
"Install it with: pip install loadout[interactive]"
|
|
28
|
+
) from None
|
|
29
|
+
|
|
30
|
+
choices = [
|
|
31
|
+
questionary.Choice(
|
|
32
|
+
title=agent.display_name or agent.name,
|
|
33
|
+
value=agent,
|
|
34
|
+
checked=True,
|
|
35
|
+
)
|
|
36
|
+
for agent in agents
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
selected = questionary.checkbox(
|
|
40
|
+
"Select agents to install to:",
|
|
41
|
+
choices=choices,
|
|
42
|
+
).ask()
|
|
43
|
+
|
|
44
|
+
return selected or []
|
loadout/_transforms.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Frontmatter parsing and content transformation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from loadout.exceptions import TransformError
|
|
11
|
+
from loadout.models import ArtifactFrontmatter
|
|
12
|
+
|
|
13
|
+
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)---\s*\n?", re.DOTALL)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_frontmatter(content: str) -> tuple[ArtifactFrontmatter, str]:
|
|
17
|
+
"""Parse YAML frontmatter from markdown content.
|
|
18
|
+
|
|
19
|
+
Returns the parsed frontmatter and the remaining body content.
|
|
20
|
+
"""
|
|
21
|
+
match = _FRONTMATTER_RE.match(content)
|
|
22
|
+
if not match:
|
|
23
|
+
return ArtifactFrontmatter(), content
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
raw: dict[str, Any] = yaml.safe_load(match.group(1)) or {}
|
|
27
|
+
except yaml.YAMLError as e:
|
|
28
|
+
raise TransformError(f"Invalid YAML frontmatter: {e}") from e
|
|
29
|
+
|
|
30
|
+
body = content[match.end() :]
|
|
31
|
+
|
|
32
|
+
known_keys = {"description", "always_apply", "alwaysApply", "globs"}
|
|
33
|
+
extra = {k: v for k, v in raw.items() if k not in known_keys}
|
|
34
|
+
|
|
35
|
+
globs_val = raw.get("globs", [])
|
|
36
|
+
if isinstance(globs_val, str):
|
|
37
|
+
globs_val = [globs_val]
|
|
38
|
+
|
|
39
|
+
fm = ArtifactFrontmatter(
|
|
40
|
+
description=raw.get("description", ""),
|
|
41
|
+
always_apply=raw.get("always_apply", raw.get("alwaysApply", False)),
|
|
42
|
+
globs=globs_val,
|
|
43
|
+
extra=extra,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return fm, body
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def strip_frontmatter(content: str) -> str:
|
|
50
|
+
"""Remove YAML frontmatter from markdown content."""
|
|
51
|
+
match = _FRONTMATTER_RE.match(content)
|
|
52
|
+
if not match:
|
|
53
|
+
return content
|
|
54
|
+
return content[match.end() :]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_cursor_frontmatter(
|
|
58
|
+
content: str,
|
|
59
|
+
description: str = "",
|
|
60
|
+
always_apply: bool = False,
|
|
61
|
+
globs: list[str] | None = None,
|
|
62
|
+
) -> str:
|
|
63
|
+
"""Add or replace frontmatter in Cursor .mdc format.
|
|
64
|
+
|
|
65
|
+
Cursor rules require description and alwaysApply fields.
|
|
66
|
+
"""
|
|
67
|
+
_, body = parse_frontmatter(content)
|
|
68
|
+
|
|
69
|
+
fm_dict: dict[str, Any] = {
|
|
70
|
+
"description": description,
|
|
71
|
+
"alwaysApply": always_apply,
|
|
72
|
+
}
|
|
73
|
+
if globs:
|
|
74
|
+
fm_dict["globs"] = globs
|
|
75
|
+
|
|
76
|
+
fm_yaml = yaml.dump(fm_dict, default_flow_style=False, sort_keys=False).strip()
|
|
77
|
+
return f"---\n{fm_yaml}\n---\n{body}"
|
loadout/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Built-in agent adapters."""
|
|
2
|
+
|
|
3
|
+
from loadout.adapters._protocol import AgentAdapter
|
|
4
|
+
from loadout.adapters.claude import ClaudeCodeAdapter
|
|
5
|
+
from loadout.adapters.cursor import CursorAdapter
|
|
6
|
+
from loadout.adapters.opencode import OpenCodeAdapter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentAdapter",
|
|
10
|
+
"ClaudeCodeAdapter",
|
|
11
|
+
"CursorAdapter",
|
|
12
|
+
"OpenCodeAdapter",
|
|
13
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Shared base adapter with common file-copy logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loadout.adapters._protocol import AgentAdapter
|
|
9
|
+
from loadout.models import (
|
|
10
|
+
Artifact,
|
|
11
|
+
ArtifactType,
|
|
12
|
+
DetectedAgent,
|
|
13
|
+
InstallResult,
|
|
14
|
+
InstallStatus,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _BaseFileAdapter(AgentAdapter):
|
|
19
|
+
"""Base adapter with shared file-copy/install logic.
|
|
20
|
+
|
|
21
|
+
Concrete adapters only need to override identity properties,
|
|
22
|
+
supported types, path resolution, and transforms.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def detect(self) -> DetectedAgent | None:
|
|
26
|
+
config_dir = Path.home() / self.config_dir_name
|
|
27
|
+
if config_dir.is_dir():
|
|
28
|
+
return DetectedAgent(
|
|
29
|
+
name=self.agent_name,
|
|
30
|
+
config_dir=config_dir,
|
|
31
|
+
display_name=self.display_name,
|
|
32
|
+
)
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def transform_content(self, artifact: Artifact, content: str) -> str:
|
|
36
|
+
return content
|
|
37
|
+
|
|
38
|
+
def transform_filename(self, artifact: Artifact, filename: str) -> str:
|
|
39
|
+
return filename
|
|
40
|
+
|
|
41
|
+
def install(
|
|
42
|
+
self, artifact: Artifact, agent: DetectedAgent, force: bool = False
|
|
43
|
+
) -> InstallResult:
|
|
44
|
+
if artifact.artifact_type not in self.supported_artifact_types():
|
|
45
|
+
return InstallResult(
|
|
46
|
+
artifact=artifact,
|
|
47
|
+
agent=agent,
|
|
48
|
+
status=InstallStatus.SKIPPED,
|
|
49
|
+
error=(
|
|
50
|
+
f"{self.display_name} does not support"
|
|
51
|
+
f" {artifact.artifact_type.value} artifacts"
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
target_path = self.get_target_path(artifact, agent.config_dir)
|
|
57
|
+
|
|
58
|
+
if target_path.exists() and not force:
|
|
59
|
+
return InstallResult(
|
|
60
|
+
artifact=artifact,
|
|
61
|
+
agent=agent,
|
|
62
|
+
status=InstallStatus.ALREADY_EXISTS,
|
|
63
|
+
target_path=target_path,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
|
|
68
|
+
source = artifact.source_path
|
|
69
|
+
if source.is_dir():
|
|
70
|
+
return self._install_directory(artifact, agent, source, target_path, force)
|
|
71
|
+
else:
|
|
72
|
+
return self._install_file(artifact, agent, source, target_path)
|
|
73
|
+
|
|
74
|
+
except Exception as e:
|
|
75
|
+
return InstallResult(
|
|
76
|
+
artifact=artifact,
|
|
77
|
+
agent=agent,
|
|
78
|
+
status=InstallStatus.FAILED,
|
|
79
|
+
error=str(e),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
def _install_file(
|
|
83
|
+
self,
|
|
84
|
+
artifact: Artifact,
|
|
85
|
+
agent: DetectedAgent,
|
|
86
|
+
source: Path,
|
|
87
|
+
target_path: Path,
|
|
88
|
+
) -> InstallResult:
|
|
89
|
+
content = source.read_text(encoding="utf-8")
|
|
90
|
+
transformed = self.transform_content(artifact, content)
|
|
91
|
+
|
|
92
|
+
final_name = self.transform_filename(artifact, target_path.name)
|
|
93
|
+
final_path = target_path.parent / final_name
|
|
94
|
+
|
|
95
|
+
final_path.write_text(transformed, encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
return InstallResult(
|
|
98
|
+
artifact=artifact,
|
|
99
|
+
agent=agent,
|
|
100
|
+
status=InstallStatus.INSTALLED,
|
|
101
|
+
target_path=final_path,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _install_directory(
|
|
105
|
+
self,
|
|
106
|
+
artifact: Artifact,
|
|
107
|
+
agent: DetectedAgent,
|
|
108
|
+
source: Path,
|
|
109
|
+
target_path: Path,
|
|
110
|
+
force: bool,
|
|
111
|
+
) -> InstallResult:
|
|
112
|
+
if target_path.exists() and force:
|
|
113
|
+
shutil.rmtree(target_path)
|
|
114
|
+
|
|
115
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
for src_file in source.rglob("*"):
|
|
118
|
+
if src_file.is_file():
|
|
119
|
+
rel = src_file.relative_to(source)
|
|
120
|
+
dst_file = target_path / rel
|
|
121
|
+
dst_file.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
|
|
123
|
+
content = src_file.read_text(encoding="utf-8")
|
|
124
|
+
transformed = self.transform_content(artifact, content)
|
|
125
|
+
|
|
126
|
+
final_name = self.transform_filename(artifact, dst_file.name)
|
|
127
|
+
final_path = dst_file.parent / final_name
|
|
128
|
+
final_path.write_text(transformed, encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
return InstallResult(
|
|
131
|
+
artifact=artifact,
|
|
132
|
+
agent=agent,
|
|
133
|
+
status=InstallStatus.INSTALLED,
|
|
134
|
+
target_path=target_path,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _get_artifact_subdir(self, artifact_type: ArtifactType) -> str:
|
|
138
|
+
"""Map artifact type to subdirectory name."""
|
|
139
|
+
return {
|
|
140
|
+
ArtifactType.SKILL: "skills",
|
|
141
|
+
ArtifactType.RULE: "rules",
|
|
142
|
+
ArtifactType.AGENT: "agents",
|
|
143
|
+
ArtifactType.COMMAND: "commands",
|
|
144
|
+
}[artifact_type]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Agent adapter abstract base class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from loadout.models import Artifact, ArtifactType, DetectedAgent, InstallResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentAdapter(ABC):
|
|
12
|
+
"""Abstract base class for coding agent adapters.
|
|
13
|
+
|
|
14
|
+
Each supported agent (Claude Code, Cursor, OpenCode, etc.) implements
|
|
15
|
+
this interface. The adapter handles detection, path resolution,
|
|
16
|
+
content transformation, and installation for its agent.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
@abstractmethod
|
|
21
|
+
def agent_name(self) -> str:
|
|
22
|
+
"""Unique identifier for this agent (e.g. 'claude', 'cursor')."""
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
@abstractmethod
|
|
26
|
+
def display_name(self) -> str:
|
|
27
|
+
"""Human-readable name (e.g. 'Claude Code', 'Cursor')."""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
@abstractmethod
|
|
31
|
+
def config_dir_name(self) -> str:
|
|
32
|
+
"""Name of the config directory (e.g. '.claude', '.cursor')."""
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
def supported_artifact_types(self) -> set[ArtifactType]:
|
|
36
|
+
"""Return the set of artifact types this agent supports."""
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def detect(self) -> DetectedAgent | None:
|
|
40
|
+
"""Detect if this agent is installed on the system.
|
|
41
|
+
|
|
42
|
+
Returns a DetectedAgent if found, None otherwise.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def get_target_path(self, artifact: Artifact, config_dir: Path) -> Path:
|
|
47
|
+
"""Resolve the target installation path for an artifact."""
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def transform_content(self, artifact: Artifact, content: str) -> str:
|
|
51
|
+
"""Transform artifact content for this agent's format."""
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def transform_filename(self, artifact: Artifact, filename: str) -> str:
|
|
55
|
+
"""Transform artifact filename for this agent's format."""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def install(
|
|
59
|
+
self, artifact: Artifact, agent: DetectedAgent, force: bool = False
|
|
60
|
+
) -> InstallResult:
|
|
61
|
+
"""Install an artifact to this agent."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Claude Code adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from loadout.adapters._base import _BaseFileAdapter
|
|
8
|
+
from loadout.models import Artifact, ArtifactType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ClaudeCodeAdapter(_BaseFileAdapter):
|
|
12
|
+
"""Adapter for Claude Code (~/.claude/)."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def agent_name(self) -> str:
|
|
16
|
+
return "claude"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def display_name(self) -> str:
|
|
20
|
+
return "Claude Code"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def config_dir_name(self) -> str:
|
|
24
|
+
return ".claude"
|
|
25
|
+
|
|
26
|
+
def supported_artifact_types(self) -> set[ArtifactType]:
|
|
27
|
+
return {ArtifactType.SKILL, ArtifactType.RULE, ArtifactType.AGENT, ArtifactType.COMMAND}
|
|
28
|
+
|
|
29
|
+
def get_target_path(self, artifact: Artifact, config_dir: Path) -> Path:
|
|
30
|
+
subdir = self._get_artifact_subdir(artifact.artifact_type)
|
|
31
|
+
|
|
32
|
+
if artifact.artifact_type == ArtifactType.SKILL:
|
|
33
|
+
# skills/<name>/ (directory)
|
|
34
|
+
return config_dir / subdir / artifact.name
|
|
35
|
+
|
|
36
|
+
if artifact.category:
|
|
37
|
+
# rules/<category>/<name>.md, agents/<category>/<name>.md
|
|
38
|
+
return config_dir / subdir / artifact.category / f"{artifact.name}.md"
|
|
39
|
+
|
|
40
|
+
if artifact.artifact_type == ArtifactType.COMMAND:
|
|
41
|
+
# commands/<name>.md
|
|
42
|
+
return config_dir / subdir / f"{artifact.name}.md"
|
|
43
|
+
|
|
44
|
+
return config_dir / subdir / f"{artifact.name}.md"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Cursor adapter with .mdc format transformation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from loadout._transforms import add_cursor_frontmatter, parse_frontmatter
|
|
8
|
+
from loadout.adapters._base import _BaseFileAdapter
|
|
9
|
+
from loadout.models import Artifact, ArtifactType
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CursorAdapter(_BaseFileAdapter):
|
|
13
|
+
"""Adapter for Cursor (~/.cursor/).
|
|
14
|
+
|
|
15
|
+
Cursor rules use .mdc format with description/alwaysApply frontmatter.
|
|
16
|
+
Skills are installed as-is.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def agent_name(self) -> str:
|
|
21
|
+
return "cursor"
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def display_name(self) -> str:
|
|
25
|
+
return "Cursor"
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def config_dir_name(self) -> str:
|
|
29
|
+
return ".cursor"
|
|
30
|
+
|
|
31
|
+
def supported_artifact_types(self) -> set[ArtifactType]:
|
|
32
|
+
return {ArtifactType.SKILL, ArtifactType.RULE}
|
|
33
|
+
|
|
34
|
+
def get_target_path(self, artifact: Artifact, config_dir: Path) -> Path:
|
|
35
|
+
subdir = self._get_artifact_subdir(artifact.artifact_type)
|
|
36
|
+
|
|
37
|
+
if artifact.artifact_type == ArtifactType.SKILL:
|
|
38
|
+
return config_dir / subdir / artifact.name
|
|
39
|
+
|
|
40
|
+
if artifact.category:
|
|
41
|
+
return config_dir / subdir / artifact.category / f"{artifact.name}.mdc"
|
|
42
|
+
|
|
43
|
+
return config_dir / subdir / f"{artifact.name}.mdc"
|
|
44
|
+
|
|
45
|
+
def transform_content(self, artifact: Artifact, content: str) -> str:
|
|
46
|
+
if artifact.artifact_type != ArtifactType.RULE:
|
|
47
|
+
return content
|
|
48
|
+
|
|
49
|
+
fm, _ = parse_frontmatter(content)
|
|
50
|
+
return add_cursor_frontmatter(
|
|
51
|
+
content,
|
|
52
|
+
description=fm.description,
|
|
53
|
+
always_apply=fm.always_apply,
|
|
54
|
+
globs=fm.globs if fm.globs else None,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def transform_filename(self, artifact: Artifact, filename: str) -> str:
|
|
58
|
+
if artifact.artifact_type == ArtifactType.RULE and filename.endswith(".md"):
|
|
59
|
+
return filename[:-3] + ".mdc"
|
|
60
|
+
return filename
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""OpenCode adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from loadout.adapters._base import _BaseFileAdapter
|
|
8
|
+
from loadout.models import Artifact, ArtifactType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenCodeAdapter(_BaseFileAdapter):
|
|
12
|
+
"""Adapter for OpenCode (~/.opencode/)."""
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def agent_name(self) -> str:
|
|
16
|
+
return "opencode"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def display_name(self) -> str:
|
|
20
|
+
return "OpenCode"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def config_dir_name(self) -> str:
|
|
24
|
+
return ".opencode"
|
|
25
|
+
|
|
26
|
+
def supported_artifact_types(self) -> set[ArtifactType]:
|
|
27
|
+
return {ArtifactType.SKILL, ArtifactType.COMMAND}
|
|
28
|
+
|
|
29
|
+
def get_target_path(self, artifact: Artifact, config_dir: Path) -> Path:
|
|
30
|
+
subdir = self._get_artifact_subdir(artifact.artifact_type)
|
|
31
|
+
|
|
32
|
+
if artifact.artifact_type == ArtifactType.SKILL:
|
|
33
|
+
return config_dir / subdir / artifact.name
|
|
34
|
+
|
|
35
|
+
# commands/<name>.md
|
|
36
|
+
return config_dir / subdir / f"{artifact.name}.md"
|
loadout/callbacks.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Callback protocol for lifecycle events during installation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from loadout.models import Artifact, DetectedAgent, InstallResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class LoadoutCallbacks(Protocol):
|
|
12
|
+
"""Protocol for receiving lifecycle events during installation.
|
|
13
|
+
|
|
14
|
+
CLIs implement this to provide custom messaging/progress.
|
|
15
|
+
All methods have default no-op behavior so implementers
|
|
16
|
+
only need to override the hooks they care about.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def on_artifact_discovered(self, artifact: Artifact) -> None: ...
|
|
20
|
+
|
|
21
|
+
def on_agent_detected(self, agent: DetectedAgent) -> None: ...
|
|
22
|
+
|
|
23
|
+
def on_install_started(self, artifact: Artifact, agent: DetectedAgent) -> None: ...
|
|
24
|
+
|
|
25
|
+
def on_install_complete(self, result: InstallResult) -> None: ...
|
|
26
|
+
|
|
27
|
+
def on_install_skipped(self, result: InstallResult) -> None: ...
|
|
28
|
+
|
|
29
|
+
def on_install_failed(self, result: InstallResult) -> None: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NoOpCallbacks:
|
|
33
|
+
"""Default no-op implementation of LoadoutCallbacks."""
|
|
34
|
+
|
|
35
|
+
def on_artifact_discovered(self, artifact: Artifact) -> None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
def on_agent_detected(self, agent: DetectedAgent) -> None:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
def on_install_started(self, artifact: Artifact, agent: DetectedAgent) -> None:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
def on_install_complete(self, result: InstallResult) -> None:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
def on_install_skipped(self, result: InstallResult) -> None:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def on_install_failed(self, result: InstallResult) -> None:
|
|
51
|
+
pass
|