cldpm 0.1.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.
cldpm/core/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Core functionality for CLDPM."""
2
+
3
+ from .config import load_cldpm_config, save_cldpm_config, load_project_config, save_project_config
4
+ from .linker import (
5
+ create_symlink,
6
+ sync_project_links,
7
+ remove_project_links,
8
+ get_local_components,
9
+ get_shared_components,
10
+ update_component_gitignore,
11
+ )
12
+ from .resolver import resolve_project
13
+
14
+ __all__ = [
15
+ "load_cldpm_config",
16
+ "save_cldpm_config",
17
+ "load_project_config",
18
+ "save_project_config",
19
+ "create_symlink",
20
+ "sync_project_links",
21
+ "remove_project_links",
22
+ "get_local_components",
23
+ "get_shared_components",
24
+ "update_component_gitignore",
25
+ "resolve_project",
26
+ ]
cldpm/core/config.py ADDED
@@ -0,0 +1,182 @@
1
+ """Configuration loading and saving for CLDPM."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..schemas import CldpmConfig, ComponentMetadata, ProjectConfig
8
+ from ..utils.fs import find_repo_root
9
+
10
+
11
+ def load_cldpm_config(repo_root: Optional[Path] = None) -> CldpmConfig:
12
+ """Load the cldpm.json configuration.
13
+
14
+ Args:
15
+ repo_root: Path to the repo root. If None, will search for it.
16
+
17
+ Returns:
18
+ The loaded CldpmConfig.
19
+
20
+ Raises:
21
+ FileNotFoundError: If cldpm.json is not found.
22
+ ValueError: If cldpm.json is invalid.
23
+ """
24
+ if repo_root is None:
25
+ repo_root = find_repo_root()
26
+ if repo_root is None:
27
+ raise FileNotFoundError(
28
+ "Not in a CLDPM mono repo. Run 'cldpm init' to create one."
29
+ )
30
+
31
+ config_path = repo_root / "cldpm.json"
32
+ if not config_path.exists():
33
+ raise FileNotFoundError(f"cldpm.json not found at {config_path}")
34
+
35
+ with open(config_path, "r") as f:
36
+ data = json.load(f)
37
+
38
+ return CldpmConfig.model_validate(data)
39
+
40
+
41
+ def save_cldpm_config(config: CldpmConfig, repo_root: Path) -> None:
42
+ """Save the cldpm.json configuration.
43
+
44
+ Args:
45
+ config: The CldpmConfig to save.
46
+ repo_root: Path to the repo root.
47
+ """
48
+ config_path = repo_root / "cldpm.json"
49
+ with open(config_path, "w") as f:
50
+ json.dump(config.model_dump(by_alias=True), f, indent=2)
51
+ f.write("\n")
52
+
53
+
54
+ def load_project_config(project_path: Path) -> ProjectConfig:
55
+ """Load a project.json configuration.
56
+
57
+ Args:
58
+ project_path: Path to the project directory.
59
+
60
+ Returns:
61
+ The loaded ProjectConfig.
62
+
63
+ Raises:
64
+ FileNotFoundError: If project.json is not found.
65
+ ValueError: If project.json is invalid.
66
+ """
67
+ config_path = project_path / "project.json"
68
+ if not config_path.exists():
69
+ raise FileNotFoundError(f"project.json not found at {config_path}")
70
+
71
+ with open(config_path, "r") as f:
72
+ data = json.load(f)
73
+
74
+ return ProjectConfig.model_validate(data)
75
+
76
+
77
+ def save_project_config(config: ProjectConfig, project_path: Path) -> None:
78
+ """Save a project.json configuration.
79
+
80
+ Args:
81
+ config: The ProjectConfig to save.
82
+ project_path: Path to the project directory.
83
+ """
84
+ config_path = project_path / "project.json"
85
+ with open(config_path, "w") as f:
86
+ json.dump(config.model_dump(exclude_none=True), f, indent=2)
87
+ f.write("\n")
88
+
89
+
90
+ def get_project_path(
91
+ project_name: str, repo_root: Optional[Path] = None
92
+ ) -> Optional[Path]:
93
+ """Get the path to a project by name.
94
+
95
+ Args:
96
+ project_name: Name of the project.
97
+ repo_root: Path to the repo root. If None, will search for it.
98
+
99
+ Returns:
100
+ Path to the project directory, or None if not found.
101
+ """
102
+ if repo_root is None:
103
+ repo_root = find_repo_root()
104
+ if repo_root is None:
105
+ return None
106
+
107
+ config = load_cldpm_config(repo_root)
108
+ project_path = repo_root / config.projects_dir / project_name
109
+
110
+ if project_path.exists() and (project_path / "project.json").exists():
111
+ return project_path
112
+
113
+ return None
114
+
115
+
116
+ def list_projects(repo_root: Optional[Path] = None) -> list[Path]:
117
+ """List all projects in the mono repo.
118
+
119
+ Args:
120
+ repo_root: Path to the repo root. If None, will search for it.
121
+
122
+ Returns:
123
+ List of paths to project directories.
124
+ """
125
+ if repo_root is None:
126
+ repo_root = find_repo_root()
127
+ if repo_root is None:
128
+ return []
129
+
130
+ config = load_cldpm_config(repo_root)
131
+ projects_dir = repo_root / config.projects_dir
132
+
133
+ if not projects_dir.exists():
134
+ return []
135
+
136
+ projects = []
137
+ for item in projects_dir.iterdir():
138
+ if item.is_dir() and (item / "project.json").exists():
139
+ projects.append(item)
140
+
141
+ return sorted(projects)
142
+
143
+
144
+ def load_component_metadata(
145
+ comp_type: str, comp_name: str, repo_root: Path
146
+ ) -> Optional[ComponentMetadata]:
147
+ """Load metadata for a shared component.
148
+
149
+ Looks for a metadata file (skill.json, agent.json, hook.json, rule.json)
150
+ in the component directory.
151
+
152
+ Args:
153
+ comp_type: Component type (skills, agents, hooks, rules).
154
+ comp_name: Component name.
155
+ repo_root: Path to the repo root.
156
+
157
+ Returns:
158
+ ComponentMetadata if found, None otherwise.
159
+ """
160
+ config = load_cldpm_config(repo_root)
161
+ shared_dir = repo_root / config.shared_dir
162
+
163
+ component_path = shared_dir / comp_type / comp_name
164
+ if not component_path.exists():
165
+ return None
166
+
167
+ # Try different metadata file names
168
+ singular_type = comp_type.rstrip("s") # skills -> skill
169
+ metadata_files = [
170
+ f"{singular_type}.json", # skill.json, agent.json, etc.
171
+ "metadata.json", # Generic fallback
172
+ ]
173
+
174
+ for metadata_file in metadata_files:
175
+ metadata_path = component_path / metadata_file
176
+ if metadata_path.exists():
177
+ with open(metadata_path, "r") as f:
178
+ data = json.load(f)
179
+ return ComponentMetadata.model_validate(data)
180
+
181
+ # Return minimal metadata if no metadata file exists
182
+ return ComponentMetadata(name=comp_name)
cldpm/core/linker.py ADDED
@@ -0,0 +1,265 @@
1
+ """Symlink management for CLDPM projects."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..schemas import ProjectConfig
8
+ from ..utils.fs import ensure_dir, find_repo_root
9
+ from .config import load_cldpm_config, load_project_config
10
+
11
+
12
+ def create_symlink(source: Path, target: Path) -> bool:
13
+ """Create a symlink from target to source.
14
+
15
+ Args:
16
+ source: The actual file/directory (symlink target).
17
+ target: The symlink path to create.
18
+
19
+ Returns:
20
+ True if successful, False otherwise.
21
+ """
22
+ # Ensure parent directory exists
23
+ ensure_dir(target.parent)
24
+
25
+ # Remove existing symlink if present
26
+ if target.is_symlink():
27
+ target.unlink()
28
+ elif target.exists():
29
+ # Don't overwrite non-symlink files
30
+ return False
31
+
32
+ # Create relative symlink
33
+ try:
34
+ # Calculate relative path from target to source
35
+ rel_path = os.path.relpath(source, target.parent)
36
+ target.symlink_to(rel_path)
37
+ return True
38
+ except OSError:
39
+ return False
40
+
41
+
42
+ def remove_project_links(project_path: Path) -> None:
43
+ """Remove all symlinks in a project's .claude directory.
44
+
45
+ Args:
46
+ project_path: Path to the project directory.
47
+ """
48
+ claude_dir = project_path / ".claude"
49
+ if not claude_dir.exists():
50
+ return
51
+
52
+ for subdir in ["skills", "agents", "hooks", "rules"]:
53
+ subdir_path = claude_dir / subdir
54
+ if not subdir_path.exists():
55
+ continue
56
+
57
+ for item in subdir_path.iterdir():
58
+ if item.is_symlink():
59
+ item.unlink()
60
+
61
+
62
+ def update_component_gitignore(component_dir: Path, symlinked_names: list[str]) -> None:
63
+ """Update the .gitignore file in a component directory to ignore only symlinks.
64
+
65
+ This allows project-specific components to be committed while shared
66
+ symlinked components are ignored.
67
+
68
+ Args:
69
+ component_dir: Path to the component directory (e.g., .claude/skills/).
70
+ symlinked_names: List of symlinked component names to ignore.
71
+ """
72
+ gitignore_path = component_dir / ".gitignore"
73
+
74
+ if not symlinked_names:
75
+ # No symlinks, remove .gitignore if it exists and only has our content
76
+ if gitignore_path.exists():
77
+ content = gitignore_path.read_text()
78
+ if content.startswith("# CLDPM shared components"):
79
+ gitignore_path.unlink()
80
+ return
81
+
82
+ # Generate .gitignore content
83
+ lines = [
84
+ "# CLDPM shared components (symlinks to shared/)",
85
+ "# These are regenerated via 'cldpm sync' - do not commit symlinks",
86
+ "# Project-specific components in this directory WILL be committed",
87
+ "",
88
+ ]
89
+ lines.extend(sorted(symlinked_names))
90
+ lines.append("") # Trailing newline
91
+
92
+ gitignore_path.write_text("\n".join(lines))
93
+
94
+
95
+ def sync_project_links(
96
+ project_path: Path, repo_root: Optional[Path] = None
97
+ ) -> dict[str, list[str]]:
98
+ """Synchronize symlinks for a project based on its dependencies.
99
+
100
+ Args:
101
+ project_path: Path to the project directory.
102
+ repo_root: Path to the repo root. If None, will search for it.
103
+
104
+ Returns:
105
+ Dictionary with lists of created, failed, and skipped links.
106
+ """
107
+ if repo_root is None:
108
+ repo_root = find_repo_root(project_path)
109
+ if repo_root is None:
110
+ raise ValueError("Not in a CLDPM mono repo")
111
+
112
+ cldpm_config = load_cldpm_config(repo_root)
113
+ project_config = load_project_config(project_path)
114
+
115
+ shared_dir = repo_root / cldpm_config.shared_dir
116
+ claude_dir = project_path / ".claude"
117
+
118
+ result = {"created": [], "failed": [], "skipped": [], "missing": []}
119
+
120
+ # Process each dependency type
121
+ dep_types = [
122
+ ("skills", project_config.dependencies.skills),
123
+ ("agents", project_config.dependencies.agents),
124
+ ("hooks", project_config.dependencies.hooks),
125
+ ("rules", project_config.dependencies.rules),
126
+ ]
127
+
128
+ for dep_type, deps in dep_types:
129
+ target_dir = claude_dir / dep_type
130
+ ensure_dir(target_dir)
131
+
132
+ # Track successfully created symlinks for .gitignore
133
+ symlinked_names = []
134
+
135
+ for dep_name in deps:
136
+ source = shared_dir / dep_type / dep_name
137
+ target = target_dir / dep_name
138
+
139
+ if not source.exists():
140
+ result["missing"].append(f"{dep_type}/{dep_name}")
141
+ continue
142
+
143
+ if target.exists() and not target.is_symlink():
144
+ # Local component exists with same name - skip but warn
145
+ result["skipped"].append(f"{dep_type}/{dep_name}")
146
+ continue
147
+
148
+ if create_symlink(source, target):
149
+ result["created"].append(f"{dep_type}/{dep_name}")
150
+ symlinked_names.append(dep_name)
151
+ else:
152
+ result["failed"].append(f"{dep_type}/{dep_name}")
153
+
154
+ # Also include existing symlinks that weren't just created
155
+ for item in target_dir.iterdir():
156
+ if item.is_symlink() and item.name not in symlinked_names:
157
+ symlinked_names.append(item.name)
158
+
159
+ # Update .gitignore in this component directory
160
+ update_component_gitignore(target_dir, symlinked_names)
161
+
162
+ return result
163
+
164
+
165
+ def add_component_link(
166
+ project_path: Path,
167
+ component_type: str,
168
+ component_name: str,
169
+ repo_root: Optional[Path] = None,
170
+ ) -> bool:
171
+ """Add a single component symlink to a project.
172
+
173
+ Args:
174
+ project_path: Path to the project directory.
175
+ component_type: Type of component (skills, agents, hooks, rules).
176
+ component_name: Name of the component.
177
+ repo_root: Path to the repo root. If None, will search for it.
178
+
179
+ Returns:
180
+ True if successful, False otherwise.
181
+ """
182
+ if repo_root is None:
183
+ repo_root = find_repo_root(project_path)
184
+ if repo_root is None:
185
+ raise ValueError("Not in a CLDPM mono repo")
186
+
187
+ cldpm_config = load_cldpm_config(repo_root)
188
+ shared_dir = repo_root / cldpm_config.shared_dir
189
+
190
+ source = shared_dir / component_type / component_name
191
+ target = project_path / ".claude" / component_type / component_name
192
+
193
+ if not source.exists():
194
+ return False
195
+
196
+ success = create_symlink(source, target)
197
+
198
+ if success:
199
+ # Update .gitignore in the component directory
200
+ component_dir = project_path / ".claude" / component_type
201
+ symlinked_names = [
202
+ item.name for item in component_dir.iterdir() if item.is_symlink()
203
+ ]
204
+ update_component_gitignore(component_dir, symlinked_names)
205
+
206
+ return success
207
+
208
+
209
+ def get_local_components(project_path: Path) -> dict[str, list[str]]:
210
+ """Get list of local (non-symlinked) components in a project.
211
+
212
+ Args:
213
+ project_path: Path to the project directory.
214
+
215
+ Returns:
216
+ Dictionary mapping component types to lists of local component names.
217
+ """
218
+ claude_dir = project_path / ".claude"
219
+ result = {}
220
+
221
+ for component_type in ["skills", "agents", "hooks", "rules"]:
222
+ type_dir = claude_dir / component_type
223
+ if not type_dir.exists():
224
+ result[component_type] = []
225
+ continue
226
+
227
+ local_components = []
228
+ for item in type_dir.iterdir():
229
+ # Skip .gitignore and symlinks
230
+ if item.name == ".gitignore":
231
+ continue
232
+ if not item.is_symlink():
233
+ local_components.append(item.name)
234
+
235
+ result[component_type] = sorted(local_components)
236
+
237
+ return result
238
+
239
+
240
+ def get_shared_components(project_path: Path) -> dict[str, list[str]]:
241
+ """Get list of shared (symlinked) components in a project.
242
+
243
+ Args:
244
+ project_path: Path to the project directory.
245
+
246
+ Returns:
247
+ Dictionary mapping component types to lists of shared component names.
248
+ """
249
+ claude_dir = project_path / ".claude"
250
+ result = {}
251
+
252
+ for component_type in ["skills", "agents", "hooks", "rules"]:
253
+ type_dir = claude_dir / component_type
254
+ if not type_dir.exists():
255
+ result[component_type] = []
256
+ continue
257
+
258
+ shared_components = []
259
+ for item in type_dir.iterdir():
260
+ if item.is_symlink():
261
+ shared_components.append(item.name)
262
+
263
+ result[component_type] = sorted(shared_components)
264
+
265
+ return result