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/__init__.py +12 -0
- cldpm/__main__.py +6 -0
- cldpm/_banner.py +99 -0
- cldpm/cli.py +81 -0
- cldpm/commands/__init__.py +12 -0
- cldpm/commands/add.py +206 -0
- cldpm/commands/clone.py +184 -0
- cldpm/commands/create.py +418 -0
- cldpm/commands/get.py +375 -0
- cldpm/commands/init.py +331 -0
- cldpm/commands/link.py +320 -0
- cldpm/commands/remove.py +289 -0
- cldpm/commands/sync.py +91 -0
- cldpm/core/__init__.py +26 -0
- cldpm/core/config.py +182 -0
- cldpm/core/linker.py +265 -0
- cldpm/core/resolver.py +291 -0
- cldpm/schemas/__init__.py +13 -0
- cldpm/schemas/cldpm.py +32 -0
- cldpm/schemas/component.py +24 -0
- cldpm/schemas/project.py +42 -0
- cldpm/templates/CLAUDE.md.j2 +22 -0
- cldpm/templates/ROOT_CLAUDE.md.j2 +34 -0
- cldpm/templates/agent.md.j2 +22 -0
- cldpm/templates/gitignore.j2 +43 -0
- cldpm/templates/hook.md.j2 +20 -0
- cldpm/templates/rule.md.j2 +33 -0
- cldpm/templates/skill.md.j2 +15 -0
- cldpm/utils/__init__.py +27 -0
- cldpm/utils/fs.py +97 -0
- cldpm/utils/git.py +169 -0
- cldpm/utils/output.py +133 -0
- cldpm-0.1.0.dist-info/METADATA +15 -0
- cldpm-0.1.0.dist-info/RECORD +37 -0
- cldpm-0.1.0.dist-info/WHEEL +4 -0
- cldpm-0.1.0.dist-info/entry_points.txt +2 -0
- cldpm-0.1.0.dist-info/licenses/LICENSE +21 -0
cldpm/commands/link.py
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Implementation of cldpm link command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..core.config import load_cldpm_config, load_component_metadata
|
|
10
|
+
from ..schemas import ComponentDependencies, ComponentMetadata
|
|
11
|
+
from ..utils.fs import find_repo_root
|
|
12
|
+
from ..utils.output import console, print_error, print_success, print_warning
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_component_spec(spec: str) -> tuple[str, str]:
|
|
16
|
+
"""Parse a component specification into type and name.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
spec: Component spec like "skill:my-skill" or "skills:my-skill".
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Tuple of (component_type, component_name).
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: If format is invalid.
|
|
26
|
+
"""
|
|
27
|
+
if ":" not in spec:
|
|
28
|
+
raise ValueError(
|
|
29
|
+
f"Invalid component format: '{spec}'. Use 'type:name' format (e.g., skill:my-skill)"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
comp_type, comp_name = spec.split(":", 1)
|
|
33
|
+
|
|
34
|
+
# Normalize type to plural form
|
|
35
|
+
type_map = {
|
|
36
|
+
"skill": "skills",
|
|
37
|
+
"skills": "skills",
|
|
38
|
+
"agent": "agents",
|
|
39
|
+
"agents": "agents",
|
|
40
|
+
"hook": "hooks",
|
|
41
|
+
"hooks": "hooks",
|
|
42
|
+
"rule": "rules",
|
|
43
|
+
"rules": "rules",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if comp_type not in type_map:
|
|
47
|
+
raise ValueError(f"Unknown component type: {comp_type}")
|
|
48
|
+
|
|
49
|
+
return type_map[comp_type], comp_name
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_component_metadata_full(
|
|
53
|
+
comp_type: str, comp_name: str, repo_root: Path
|
|
54
|
+
) -> tuple[Optional[ComponentMetadata], Path]:
|
|
55
|
+
"""Load component metadata and return the metadata file path.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
comp_type: Component type (skills, agents, hooks, rules).
|
|
59
|
+
comp_name: Component name.
|
|
60
|
+
repo_root: Path to the repo root.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Tuple of (ComponentMetadata or None, metadata_file_path).
|
|
64
|
+
"""
|
|
65
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
66
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
67
|
+
|
|
68
|
+
component_path = shared_dir / comp_type / comp_name
|
|
69
|
+
if not component_path.exists():
|
|
70
|
+
return None, component_path
|
|
71
|
+
|
|
72
|
+
singular_type = comp_type.rstrip("s")
|
|
73
|
+
metadata_path = component_path / f"{singular_type}.json"
|
|
74
|
+
|
|
75
|
+
if metadata_path.exists():
|
|
76
|
+
with open(metadata_path, "r") as f:
|
|
77
|
+
data = json.load(f)
|
|
78
|
+
return ComponentMetadata.model_validate(data), metadata_path
|
|
79
|
+
|
|
80
|
+
# Return minimal metadata if no file exists
|
|
81
|
+
return ComponentMetadata(name=comp_name), metadata_path
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def save_component_metadata(metadata: ComponentMetadata, metadata_path: Path) -> None:
|
|
85
|
+
"""Save component metadata to a JSON file.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
metadata: The ComponentMetadata to save.
|
|
89
|
+
metadata_path: Path to the metadata file.
|
|
90
|
+
"""
|
|
91
|
+
data = {"name": metadata.name}
|
|
92
|
+
|
|
93
|
+
if metadata.description:
|
|
94
|
+
data["description"] = metadata.description
|
|
95
|
+
|
|
96
|
+
deps = metadata.dependencies
|
|
97
|
+
if deps.skills or deps.agents or deps.hooks or deps.rules:
|
|
98
|
+
data["dependencies"] = {}
|
|
99
|
+
if deps.skills:
|
|
100
|
+
data["dependencies"]["skills"] = deps.skills
|
|
101
|
+
if deps.agents:
|
|
102
|
+
data["dependencies"]["agents"] = deps.agents
|
|
103
|
+
if deps.hooks:
|
|
104
|
+
data["dependencies"]["hooks"] = deps.hooks
|
|
105
|
+
if deps.rules:
|
|
106
|
+
data["dependencies"]["rules"] = deps.rules
|
|
107
|
+
|
|
108
|
+
# Preserve any extra fields from the original
|
|
109
|
+
with open(metadata_path, "w") as f:
|
|
110
|
+
json.dump(data, f, indent=2)
|
|
111
|
+
f.write("\n")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@click.command()
|
|
115
|
+
@click.argument("dependencies")
|
|
116
|
+
@click.option("--to", "-t", "target", required=True, help="Target component (type:name)")
|
|
117
|
+
def link(dependencies: str, target: str) -> None:
|
|
118
|
+
"""Link dependencies to an existing shared component.
|
|
119
|
+
|
|
120
|
+
Adds one or more components as dependencies of another component.
|
|
121
|
+
This updates the target component's metadata file.
|
|
122
|
+
|
|
123
|
+
\b
|
|
124
|
+
DEPENDENCIES format:
|
|
125
|
+
type:name - Single dependency
|
|
126
|
+
type:a,type:b,type:c - Multiple dependencies
|
|
127
|
+
|
|
128
|
+
\b
|
|
129
|
+
TARGET format:
|
|
130
|
+
type:name - The component to add dependencies to
|
|
131
|
+
|
|
132
|
+
\b
|
|
133
|
+
Examples:
|
|
134
|
+
cldpm link skill:base-utils --to skill:advanced-review
|
|
135
|
+
cldpm link rule:security --to agent:security-audit
|
|
136
|
+
cldpm link skill:scan,skill:review,rule:security --to agent:auditor
|
|
137
|
+
"""
|
|
138
|
+
# Find repo root
|
|
139
|
+
repo_root = find_repo_root()
|
|
140
|
+
if repo_root is None:
|
|
141
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
142
|
+
raise SystemExit(1)
|
|
143
|
+
|
|
144
|
+
# Parse target component
|
|
145
|
+
try:
|
|
146
|
+
target_type, target_name = parse_component_spec(target)
|
|
147
|
+
except ValueError as e:
|
|
148
|
+
print_error(str(e))
|
|
149
|
+
raise SystemExit(1)
|
|
150
|
+
|
|
151
|
+
# Load target component metadata
|
|
152
|
+
target_metadata, metadata_path = load_component_metadata_full(
|
|
153
|
+
target_type, target_name, repo_root
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if target_metadata is None:
|
|
157
|
+
print_error(f"Target component not found: {target_type}/{target_name}")
|
|
158
|
+
raise SystemExit(1)
|
|
159
|
+
|
|
160
|
+
# Ensure metadata file exists
|
|
161
|
+
if not metadata_path.exists():
|
|
162
|
+
# Create minimal metadata file
|
|
163
|
+
metadata_path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
target_metadata = ComponentMetadata(
|
|
165
|
+
name=target_name,
|
|
166
|
+
dependencies=ComponentDependencies(),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Parse dependency specifications
|
|
170
|
+
dep_specs = [d.strip() for d in dependencies.split(",") if d.strip()]
|
|
171
|
+
|
|
172
|
+
if not dep_specs:
|
|
173
|
+
print_error("No dependencies specified")
|
|
174
|
+
raise SystemExit(1)
|
|
175
|
+
|
|
176
|
+
# Load CLDPM config to check if dependencies exist
|
|
177
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
178
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
179
|
+
|
|
180
|
+
# Process each dependency
|
|
181
|
+
added = []
|
|
182
|
+
already_linked = []
|
|
183
|
+
not_found = []
|
|
184
|
+
|
|
185
|
+
for dep_spec in dep_specs:
|
|
186
|
+
try:
|
|
187
|
+
dep_type, dep_name = parse_component_spec(dep_spec)
|
|
188
|
+
except ValueError as e:
|
|
189
|
+
print_error(str(e))
|
|
190
|
+
raise SystemExit(1)
|
|
191
|
+
|
|
192
|
+
# Check if dependency exists
|
|
193
|
+
dep_path = shared_dir / dep_type / dep_name
|
|
194
|
+
if not dep_path.exists():
|
|
195
|
+
not_found.append(f"{dep_type}/{dep_name}")
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Get the appropriate dependency list
|
|
199
|
+
dep_list = getattr(target_metadata.dependencies, dep_type)
|
|
200
|
+
|
|
201
|
+
# Check if already linked
|
|
202
|
+
if dep_name in dep_list:
|
|
203
|
+
already_linked.append(f"{dep_type}/{dep_name}")
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
# Add dependency
|
|
207
|
+
dep_list.append(dep_name)
|
|
208
|
+
added.append(f"{dep_type}/{dep_name}")
|
|
209
|
+
|
|
210
|
+
# Save updated metadata
|
|
211
|
+
if added:
|
|
212
|
+
save_component_metadata(target_metadata, metadata_path)
|
|
213
|
+
print_success(f"Linked dependencies to {target_type}/{target_name}")
|
|
214
|
+
for dep in added:
|
|
215
|
+
console.print(f" [green]✓[/green] {dep}")
|
|
216
|
+
|
|
217
|
+
if already_linked:
|
|
218
|
+
print_warning("Already linked:")
|
|
219
|
+
for dep in already_linked:
|
|
220
|
+
console.print(f" [dim]-[/dim] {dep}")
|
|
221
|
+
|
|
222
|
+
if not_found:
|
|
223
|
+
print_error("Dependencies not found:")
|
|
224
|
+
for dep in not_found:
|
|
225
|
+
console.print(f" [red]✗[/red] {dep}")
|
|
226
|
+
if not added:
|
|
227
|
+
raise SystemExit(1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@click.command()
|
|
231
|
+
@click.argument("dependencies")
|
|
232
|
+
@click.option("--from", "-f", "target", required=True, help="Target component (type:name)")
|
|
233
|
+
def unlink(dependencies: str, target: str) -> None:
|
|
234
|
+
"""Remove dependencies from an existing shared component.
|
|
235
|
+
|
|
236
|
+
Removes one or more components as dependencies of another component.
|
|
237
|
+
This updates the target component's metadata file.
|
|
238
|
+
|
|
239
|
+
\b
|
|
240
|
+
DEPENDENCIES format:
|
|
241
|
+
type:name - Single dependency
|
|
242
|
+
type:a,type:b,type:c - Multiple dependencies
|
|
243
|
+
|
|
244
|
+
\b
|
|
245
|
+
TARGET format:
|
|
246
|
+
type:name - The component to remove dependencies from
|
|
247
|
+
|
|
248
|
+
\b
|
|
249
|
+
Examples:
|
|
250
|
+
cldpm unlink skill:base-utils --from skill:advanced-review
|
|
251
|
+
cldpm unlink rule:security --from agent:security-audit
|
|
252
|
+
cldpm unlink skill:scan,skill:review --from agent:auditor
|
|
253
|
+
"""
|
|
254
|
+
# Find repo root
|
|
255
|
+
repo_root = find_repo_root()
|
|
256
|
+
if repo_root is None:
|
|
257
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
258
|
+
raise SystemExit(1)
|
|
259
|
+
|
|
260
|
+
# Parse target component
|
|
261
|
+
try:
|
|
262
|
+
target_type, target_name = parse_component_spec(target)
|
|
263
|
+
except ValueError as e:
|
|
264
|
+
print_error(str(e))
|
|
265
|
+
raise SystemExit(1)
|
|
266
|
+
|
|
267
|
+
# Load target component metadata
|
|
268
|
+
target_metadata, metadata_path = load_component_metadata_full(
|
|
269
|
+
target_type, target_name, repo_root
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if target_metadata is None:
|
|
273
|
+
print_error(f"Target component not found: {target_type}/{target_name}")
|
|
274
|
+
raise SystemExit(1)
|
|
275
|
+
|
|
276
|
+
if not metadata_path.exists():
|
|
277
|
+
print_error(f"No metadata file for: {target_type}/{target_name}")
|
|
278
|
+
raise SystemExit(1)
|
|
279
|
+
|
|
280
|
+
# Parse dependency specifications
|
|
281
|
+
dep_specs = [d.strip() for d in dependencies.split(",") if d.strip()]
|
|
282
|
+
|
|
283
|
+
if not dep_specs:
|
|
284
|
+
print_error("No dependencies specified")
|
|
285
|
+
raise SystemExit(1)
|
|
286
|
+
|
|
287
|
+
# Process each dependency
|
|
288
|
+
removed = []
|
|
289
|
+
not_linked = []
|
|
290
|
+
|
|
291
|
+
for dep_spec in dep_specs:
|
|
292
|
+
try:
|
|
293
|
+
dep_type, dep_name = parse_component_spec(dep_spec)
|
|
294
|
+
except ValueError as e:
|
|
295
|
+
print_error(str(e))
|
|
296
|
+
raise SystemExit(1)
|
|
297
|
+
|
|
298
|
+
# Get the appropriate dependency list
|
|
299
|
+
dep_list = getattr(target_metadata.dependencies, dep_type)
|
|
300
|
+
|
|
301
|
+
# Check if linked
|
|
302
|
+
if dep_name not in dep_list:
|
|
303
|
+
not_linked.append(f"{dep_type}/{dep_name}")
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
# Remove dependency
|
|
307
|
+
dep_list.remove(dep_name)
|
|
308
|
+
removed.append(f"{dep_type}/{dep_name}")
|
|
309
|
+
|
|
310
|
+
# Save updated metadata
|
|
311
|
+
if removed:
|
|
312
|
+
save_component_metadata(target_metadata, metadata_path)
|
|
313
|
+
print_success(f"Unlinked dependencies from {target_type}/{target_name}")
|
|
314
|
+
for dep in removed:
|
|
315
|
+
console.print(f" [green]✓[/green] {dep}")
|
|
316
|
+
|
|
317
|
+
if not_linked:
|
|
318
|
+
print_warning("Not linked (skipped):")
|
|
319
|
+
for dep in not_linked:
|
|
320
|
+
console.print(f" [dim]-[/dim] {dep}")
|
cldpm/commands/remove.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Implementation of cldpm remove command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.config import (
|
|
8
|
+
get_project_path,
|
|
9
|
+
load_cldpm_config,
|
|
10
|
+
load_project_config,
|
|
11
|
+
save_project_config,
|
|
12
|
+
)
|
|
13
|
+
from ..core.linker import update_component_gitignore
|
|
14
|
+
from ..core.resolver import get_all_dependencies_for_component
|
|
15
|
+
from ..utils.fs import find_repo_root
|
|
16
|
+
from ..utils.output import console, print_error, print_success, print_warning
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_component(component: str, repo_root: Path) -> tuple[str, str]:
|
|
20
|
+
"""Parse a component specification into type and name.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
component: Component spec like "skill:my-skill" or just "my-skill".
|
|
24
|
+
repo_root: Path to the repo root for auto-detection.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tuple of (component_type, component_name).
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
ValueError: If component type cannot be determined.
|
|
31
|
+
"""
|
|
32
|
+
if ":" in component:
|
|
33
|
+
comp_type, comp_name = component.split(":", 1)
|
|
34
|
+
type_map = {
|
|
35
|
+
"skill": "skills",
|
|
36
|
+
"skills": "skills",
|
|
37
|
+
"agent": "agents",
|
|
38
|
+
"agents": "agents",
|
|
39
|
+
"hook": "hooks",
|
|
40
|
+
"hooks": "hooks",
|
|
41
|
+
"rule": "rules",
|
|
42
|
+
"rules": "rules",
|
|
43
|
+
}
|
|
44
|
+
if comp_type not in type_map:
|
|
45
|
+
raise ValueError(f"Unknown component type: {comp_type}")
|
|
46
|
+
return type_map[comp_type], comp_name
|
|
47
|
+
|
|
48
|
+
# Auto-detect from project dependencies
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Component '{component}' type not specified. Use 'type:name' format."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_component_dependents(
|
|
55
|
+
comp_type: str,
|
|
56
|
+
comp_name: str,
|
|
57
|
+
project_path: Path,
|
|
58
|
+
repo_root: Path,
|
|
59
|
+
) -> list[tuple[str, str]]:
|
|
60
|
+
"""Find components in the project that depend on the given component.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
comp_type: Component type being removed.
|
|
64
|
+
comp_name: Component name being removed.
|
|
65
|
+
project_path: Path to the project directory.
|
|
66
|
+
repo_root: Path to the repo root.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of (comp_type, comp_name) tuples that depend on this component.
|
|
70
|
+
"""
|
|
71
|
+
project_config = load_project_config(project_path)
|
|
72
|
+
dependents = []
|
|
73
|
+
|
|
74
|
+
# Check all components in the project
|
|
75
|
+
for check_type in ["skills", "agents", "hooks", "rules"]:
|
|
76
|
+
check_list = getattr(project_config.dependencies, check_type)
|
|
77
|
+
for check_name in check_list:
|
|
78
|
+
if check_type == comp_type and check_name == comp_name:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# Get dependencies of this component
|
|
82
|
+
deps = get_all_dependencies_for_component(check_type, check_name, repo_root)
|
|
83
|
+
dep_list = deps.get(comp_type, [])
|
|
84
|
+
|
|
85
|
+
if comp_name in dep_list:
|
|
86
|
+
dependents.append((check_type, check_name))
|
|
87
|
+
|
|
88
|
+
return dependents
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def find_orphaned_dependencies(
|
|
92
|
+
comp_type: str,
|
|
93
|
+
comp_name: str,
|
|
94
|
+
project_path: Path,
|
|
95
|
+
repo_root: Path,
|
|
96
|
+
) -> list[tuple[str, str]]:
|
|
97
|
+
"""Find dependencies that would become orphaned after removal.
|
|
98
|
+
|
|
99
|
+
A dependency is orphaned if no other component in the project uses it.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
comp_type: Component type being removed.
|
|
103
|
+
comp_name: Component name being removed.
|
|
104
|
+
project_path: Path to the project directory.
|
|
105
|
+
repo_root: Path to the repo root.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of (comp_type, comp_name) tuples that would be orphaned.
|
|
109
|
+
"""
|
|
110
|
+
project_config = load_project_config(project_path)
|
|
111
|
+
|
|
112
|
+
# Get dependencies of the component being removed
|
|
113
|
+
removed_deps = get_all_dependencies_for_component(comp_type, comp_name, repo_root)
|
|
114
|
+
|
|
115
|
+
orphaned = []
|
|
116
|
+
|
|
117
|
+
for dep_type, dep_names in removed_deps.items():
|
|
118
|
+
for dep_name in dep_names:
|
|
119
|
+
# Check if any other component in the project uses this dependency
|
|
120
|
+
is_used = False
|
|
121
|
+
|
|
122
|
+
for check_type in ["skills", "agents", "hooks", "rules"]:
|
|
123
|
+
check_list = getattr(project_config.dependencies, check_type)
|
|
124
|
+
for check_name in check_list:
|
|
125
|
+
# Skip the component being removed
|
|
126
|
+
if check_type == comp_type and check_name == comp_name:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Skip the dependency itself (it's a direct dependency)
|
|
130
|
+
if check_type == dep_type and check_name == dep_name:
|
|
131
|
+
is_used = True
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
# Check if this component depends on the dependency
|
|
135
|
+
check_deps = get_all_dependencies_for_component(
|
|
136
|
+
check_type, check_name, repo_root
|
|
137
|
+
)
|
|
138
|
+
if dep_name in check_deps.get(dep_type, []):
|
|
139
|
+
is_used = True
|
|
140
|
+
break
|
|
141
|
+
|
|
142
|
+
if is_used:
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
if not is_used:
|
|
146
|
+
orphaned.append((dep_type, dep_name))
|
|
147
|
+
|
|
148
|
+
return orphaned
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def remove_single_component(
|
|
152
|
+
comp_type: str,
|
|
153
|
+
comp_name: str,
|
|
154
|
+
project_path: Path,
|
|
155
|
+
repo_root: Path,
|
|
156
|
+
) -> bool:
|
|
157
|
+
"""Remove a single component from a project.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
comp_type: Component type (skills, agents, hooks, rules).
|
|
161
|
+
comp_name: Component name.
|
|
162
|
+
project_path: Path to the project directory.
|
|
163
|
+
repo_root: Path to the repo root.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
True if removed successfully, False otherwise.
|
|
167
|
+
"""
|
|
168
|
+
project_config = load_project_config(project_path)
|
|
169
|
+
deps_list = getattr(project_config.dependencies, comp_type)
|
|
170
|
+
|
|
171
|
+
if comp_name not in deps_list:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
# Remove from dependencies
|
|
175
|
+
deps_list.remove(comp_name)
|
|
176
|
+
save_project_config(project_config, project_path)
|
|
177
|
+
|
|
178
|
+
# Remove symlink
|
|
179
|
+
remove_component_link(project_path, comp_type, comp_name)
|
|
180
|
+
|
|
181
|
+
# Update gitignore
|
|
182
|
+
component_dir = project_path / ".claude" / comp_type
|
|
183
|
+
if component_dir.exists():
|
|
184
|
+
# Get remaining symlinks
|
|
185
|
+
remaining_symlinks = [
|
|
186
|
+
item.name
|
|
187
|
+
for item in component_dir.iterdir()
|
|
188
|
+
if item.is_symlink()
|
|
189
|
+
]
|
|
190
|
+
update_component_gitignore(component_dir, remaining_symlinks)
|
|
191
|
+
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def remove_component_link(project_path: Path, comp_type: str, comp_name: str) -> None:
|
|
196
|
+
"""Remove a component symlink from a project.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
project_path: Path to the project directory.
|
|
200
|
+
comp_type: Component type (skills, agents, hooks, rules).
|
|
201
|
+
comp_name: Component name.
|
|
202
|
+
"""
|
|
203
|
+
link_path = project_path / ".claude" / comp_type / comp_name
|
|
204
|
+
if link_path.is_symlink():
|
|
205
|
+
link_path.unlink()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@click.command()
|
|
209
|
+
@click.argument("component")
|
|
210
|
+
@click.option("--from", "-f", "project_name", required=True, help="Target project name")
|
|
211
|
+
@click.option("--keep-deps", is_flag=True, help="Keep orphaned dependencies")
|
|
212
|
+
@click.option("--force", is_flag=True, help="Remove even if other components depend on it")
|
|
213
|
+
def remove(component: str, project_name: str, keep_deps: bool, force: bool) -> None:
|
|
214
|
+
"""Remove a shared component from a project.
|
|
215
|
+
|
|
216
|
+
Removes the component from project.json and deletes the symlink.
|
|
217
|
+
By default, also offers to remove orphaned dependencies.
|
|
218
|
+
|
|
219
|
+
\b
|
|
220
|
+
COMPONENT format:
|
|
221
|
+
type:name - Explicit type (skill, agent, hook, rule)
|
|
222
|
+
|
|
223
|
+
\b
|
|
224
|
+
Examples:
|
|
225
|
+
cldpm remove skill:my-skill --from my-project
|
|
226
|
+
cldpm remove agent:pentester -f my-project
|
|
227
|
+
cldpm remove skill:common --from my-project --keep-deps
|
|
228
|
+
cldpm remove skill:shared --from my-project --force
|
|
229
|
+
"""
|
|
230
|
+
# Find repo root
|
|
231
|
+
repo_root = find_repo_root()
|
|
232
|
+
if repo_root is None:
|
|
233
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
234
|
+
raise SystemExit(1)
|
|
235
|
+
|
|
236
|
+
# Get project path
|
|
237
|
+
project_path = get_project_path(project_name, repo_root)
|
|
238
|
+
if project_path is None:
|
|
239
|
+
print_error(f"Project not found: {project_name}")
|
|
240
|
+
raise SystemExit(1)
|
|
241
|
+
|
|
242
|
+
# Parse component
|
|
243
|
+
try:
|
|
244
|
+
comp_type, comp_name = parse_component(component, repo_root)
|
|
245
|
+
except ValueError as e:
|
|
246
|
+
print_error(str(e))
|
|
247
|
+
raise SystemExit(1)
|
|
248
|
+
|
|
249
|
+
# Check if component is in project
|
|
250
|
+
project_config = load_project_config(project_path)
|
|
251
|
+
deps_list = getattr(project_config.dependencies, comp_type)
|
|
252
|
+
|
|
253
|
+
if comp_name not in deps_list:
|
|
254
|
+
print_error(f"Component not in project: {comp_type}/{comp_name}")
|
|
255
|
+
raise SystemExit(1)
|
|
256
|
+
|
|
257
|
+
# Check for dependents
|
|
258
|
+
dependents = get_component_dependents(comp_type, comp_name, project_path, repo_root)
|
|
259
|
+
if dependents and not force:
|
|
260
|
+
print_error(f"Cannot remove {comp_type}/{comp_name}: other components depend on it")
|
|
261
|
+
for dep_type, dep_name in dependents:
|
|
262
|
+
console.print(f" - {dep_type}/{dep_name}")
|
|
263
|
+
console.print("\nUse --force to remove anyway.")
|
|
264
|
+
raise SystemExit(1)
|
|
265
|
+
|
|
266
|
+
# Find orphaned dependencies
|
|
267
|
+
orphaned = []
|
|
268
|
+
if not keep_deps:
|
|
269
|
+
orphaned = find_orphaned_dependencies(comp_type, comp_name, project_path, repo_root)
|
|
270
|
+
|
|
271
|
+
# Remove the main component
|
|
272
|
+
if remove_single_component(comp_type, comp_name, project_path, repo_root):
|
|
273
|
+
print_success(f"Removed {comp_type}/{comp_name} from {project_name}")
|
|
274
|
+
else:
|
|
275
|
+
print_error(f"Failed to remove {comp_type}/{comp_name}")
|
|
276
|
+
raise SystemExit(1)
|
|
277
|
+
|
|
278
|
+
# Handle orphaned dependencies
|
|
279
|
+
if orphaned:
|
|
280
|
+
console.print("\n[dim]Orphaned dependencies:[/dim]")
|
|
281
|
+
for dep_type, dep_name in orphaned:
|
|
282
|
+
console.print(f" - {dep_type}/{dep_name}")
|
|
283
|
+
|
|
284
|
+
if click.confirm("\nRemove orphaned dependencies?", default=True):
|
|
285
|
+
for dep_type, dep_name in orphaned:
|
|
286
|
+
if remove_single_component(dep_type, dep_name, project_path, repo_root):
|
|
287
|
+
console.print(f" [green]✓[/green] Removed {dep_type}/{dep_name}")
|
|
288
|
+
else:
|
|
289
|
+
console.print(f" [yellow]![/yellow] Failed to remove {dep_type}/{dep_name}")
|
cldpm/commands/sync.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Implementation of cldpm sync command."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from ..core.config import get_project_path, list_projects
|
|
8
|
+
from ..core.linker import remove_project_links, sync_project_links
|
|
9
|
+
from ..utils.fs import find_repo_root
|
|
10
|
+
from ..utils.output import console, print_error, print_success, print_warning
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.command()
|
|
14
|
+
@click.argument("project_name", required=False)
|
|
15
|
+
@click.option("--all", "-a", "sync_all", is_flag=True, help="Sync all projects")
|
|
16
|
+
def sync(project_name: Optional[str], sync_all: bool) -> None:
|
|
17
|
+
"""Regenerate symlinks for shared components.
|
|
18
|
+
|
|
19
|
+
Recreates symlinks from project's .claude/ directories to shared/
|
|
20
|
+
based on dependencies in project.json. Also updates per-directory
|
|
21
|
+
.gitignore files to ignore only the symlinked components.
|
|
22
|
+
|
|
23
|
+
\b
|
|
24
|
+
When to use:
|
|
25
|
+
- After 'git clone' (symlinks aren't committed)
|
|
26
|
+
- After adding dependencies to project.json manually
|
|
27
|
+
- When symlinks are broken or missing
|
|
28
|
+
|
|
29
|
+
\b
|
|
30
|
+
Note: Local (project-specific) components are not affected.
|
|
31
|
+
|
|
32
|
+
\b
|
|
33
|
+
Examples:
|
|
34
|
+
cldpm sync my-project # Single project
|
|
35
|
+
cldpm sync --all # All projects
|
|
36
|
+
cldpm sync -a # All projects (short)
|
|
37
|
+
"""
|
|
38
|
+
# Find repo root
|
|
39
|
+
repo_root = find_repo_root()
|
|
40
|
+
if repo_root is None:
|
|
41
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
42
|
+
raise SystemExit(1)
|
|
43
|
+
|
|
44
|
+
# Determine which projects to sync
|
|
45
|
+
if sync_all:
|
|
46
|
+
projects = list_projects(repo_root)
|
|
47
|
+
if not projects:
|
|
48
|
+
print_warning("No projects found in mono repo")
|
|
49
|
+
return
|
|
50
|
+
elif project_name:
|
|
51
|
+
project_path = get_project_path(project_name, repo_root)
|
|
52
|
+
if project_path is None:
|
|
53
|
+
print_error(f"Project not found: {project_name}")
|
|
54
|
+
raise SystemExit(1)
|
|
55
|
+
projects = [project_path]
|
|
56
|
+
else:
|
|
57
|
+
print_error("Specify a project name or use --all to sync all projects")
|
|
58
|
+
raise SystemExit(1)
|
|
59
|
+
|
|
60
|
+
# Sync each project
|
|
61
|
+
for project_path in projects:
|
|
62
|
+
project_name = project_path.name
|
|
63
|
+
|
|
64
|
+
# Remove existing symlinks
|
|
65
|
+
remove_project_links(project_path)
|
|
66
|
+
|
|
67
|
+
# Create fresh symlinks
|
|
68
|
+
result = sync_project_links(project_path, repo_root)
|
|
69
|
+
|
|
70
|
+
# Report results
|
|
71
|
+
if result["created"]:
|
|
72
|
+
print_success(f"{project_name}: synced {len(result['created'])} links")
|
|
73
|
+
for link in result["created"]:
|
|
74
|
+
console.print(f" [green]✓[/green] {link}")
|
|
75
|
+
elif not result["missing"] and not result["failed"]:
|
|
76
|
+
console.print(f"[dim]{project_name}: no dependencies to sync[/dim]")
|
|
77
|
+
|
|
78
|
+
if result["missing"]:
|
|
79
|
+
print_warning(f"{project_name}: {len(result['missing'])} missing components")
|
|
80
|
+
for link in result["missing"]:
|
|
81
|
+
console.print(f" [yellow]![/yellow] {link}")
|
|
82
|
+
|
|
83
|
+
if result["failed"]:
|
|
84
|
+
print_error(f"{project_name}: {len(result['failed'])} failed links")
|
|
85
|
+
for link in result["failed"]:
|
|
86
|
+
console.print(f" [red]✗[/red] {link}")
|
|
87
|
+
|
|
88
|
+
if result["skipped"]:
|
|
89
|
+
console.print(
|
|
90
|
+
f" [dim]Skipped {len(result['skipped'])} existing files[/dim]"
|
|
91
|
+
)
|