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/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}")
@@ -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
+ )