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 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