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/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""CLDPM - Claude Project Manager.
|
|
2
|
+
|
|
3
|
+
An SDK and CLI for managing mono repos with multiple Claude Code projects.
|
|
4
|
+
|
|
5
|
+
Crafted by Transilience.ai
|
|
6
|
+
Authored by Aman Agarwal (https://github.com/amanagarwal041)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__author__ = "Aman Agarwal"
|
|
11
|
+
__author_url__ = "https://github.com/amanagarwal041"
|
|
12
|
+
__crafted_by__ = "Transilience.ai"
|
cldpm/__main__.py
ADDED
cldpm/_banner.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""CLDPM Banner Display Module.
|
|
2
|
+
|
|
3
|
+
Displays decorative information about CLDPM, author, and Transilience.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
BANNER_ASCII = r"""
|
|
11
|
+
██████╗██╗ ██████╗ ██████╗ ███╗ ███╗
|
|
12
|
+
██╔════╝██║ ██╔══██╗██╔══██╗████╗ ████║
|
|
13
|
+
██║ ██║ ██║ ██║██████╔╝██╔████╔██║
|
|
14
|
+
██║ ██║ ██║ ██║██╔═══╝ ██║╚██╔╝██║
|
|
15
|
+
╚██████╗███████╗██████╔╝██║ ██║ ╚═╝ ██║
|
|
16
|
+
╚═════╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═╝
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_banner(console: Console | None = None) -> None:
|
|
21
|
+
"""Print the CLDPM installation/info banner."""
|
|
22
|
+
if console is None:
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
# ASCII art
|
|
26
|
+
ascii_text = Text(BANNER_ASCII, style="bold magenta")
|
|
27
|
+
console.print(ascii_text)
|
|
28
|
+
|
|
29
|
+
# Title
|
|
30
|
+
console.print(" [bold cyan]Claude Project Manager[/bold cyan]")
|
|
31
|
+
console.print(" [dim]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/dim]")
|
|
32
|
+
console.print()
|
|
33
|
+
|
|
34
|
+
# Description
|
|
35
|
+
console.print(" [white]Manage mono repos with multiple Claude Code[/white]")
|
|
36
|
+
console.print(" [white]projects. Share skills, agents, hooks, and[/white]")
|
|
37
|
+
console.print(" [white]rules across projects without duplication.[/white]")
|
|
38
|
+
console.print()
|
|
39
|
+
console.print(" [dim]─────────────────────────────────────────[/dim]")
|
|
40
|
+
console.print()
|
|
41
|
+
|
|
42
|
+
# Quick Start
|
|
43
|
+
console.print(" [yellow]Quick Start:[/yellow]")
|
|
44
|
+
console.print(" [dim]$[/dim] [white]cldpm init my-monorepo[/white]")
|
|
45
|
+
console.print(" [dim]$[/dim] [white]cldpm create project web-app[/white]")
|
|
46
|
+
console.print(" [dim]$[/dim] [white]cldpm create skill logging[/white]")
|
|
47
|
+
console.print(" [dim]$[/dim] [white]cldpm add skill:logging --to web-app[/white]")
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(" [dim]─────────────────────────────────────────[/dim]")
|
|
50
|
+
console.print()
|
|
51
|
+
|
|
52
|
+
# Attribution
|
|
53
|
+
console.print(" [magenta]◆[/magenta] [dim]Crafted by[/dim] [cyan]Transilience.ai[/cyan]")
|
|
54
|
+
console.print(" [magenta]◆[/magenta] [dim]Authored by[/dim] [white]Aman Agarwal[/white]")
|
|
55
|
+
console.print(" [dim]github.com/amanagarwal041[/dim]")
|
|
56
|
+
console.print()
|
|
57
|
+
console.print(" [dim]─────────────────────────────────────────[/dim]")
|
|
58
|
+
console.print()
|
|
59
|
+
|
|
60
|
+
# Links
|
|
61
|
+
console.print(" [dim]Docs:[/dim] [cyan]https://cldpm.transilience.ai[/cyan]")
|
|
62
|
+
console.print(" [dim]GitHub:[/dim] [cyan]https://github.com/transilienceai/cldpm[/cyan]")
|
|
63
|
+
console.print()
|
|
64
|
+
console.print(" [dim]━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━[/dim]")
|
|
65
|
+
console.print()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_banner_text() -> str:
|
|
69
|
+
"""Get banner as plain text for non-TTY environments."""
|
|
70
|
+
return f"""
|
|
71
|
+
{BANNER_ASCII}
|
|
72
|
+
Claude Project Manager
|
|
73
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
74
|
+
|
|
75
|
+
Manage mono repos with multiple Claude Code
|
|
76
|
+
projects. Share skills, agents, hooks, and
|
|
77
|
+
rules across projects without duplication.
|
|
78
|
+
|
|
79
|
+
─────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
Quick Start:
|
|
82
|
+
$ cldpm init my-monorepo
|
|
83
|
+
$ cldpm create project web-app
|
|
84
|
+
$ cldpm create skill logging
|
|
85
|
+
$ cldpm add skill:logging --to web-app
|
|
86
|
+
|
|
87
|
+
─────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
◆ Crafted by Transilience.ai
|
|
90
|
+
◆ Authored by Aman Agarwal
|
|
91
|
+
github.com/amanagarwal041
|
|
92
|
+
|
|
93
|
+
─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
Docs: https://cldpm.transilience.ai
|
|
96
|
+
GitHub: https://github.com/transilienceai/cldpm
|
|
97
|
+
|
|
98
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
99
|
+
"""
|
cldpm/cli.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CLI entry point for CLDPM."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from .commands import init, create, add, remove, link, unlink, get, clone, sync
|
|
6
|
+
from ._banner import print_banner
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def show_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
|
|
10
|
+
"""Custom version callback that shows banner."""
|
|
11
|
+
if not value or ctx.resilient_parsing:
|
|
12
|
+
return
|
|
13
|
+
print_banner()
|
|
14
|
+
ctx.exit()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@click.group()
|
|
18
|
+
@click.option(
|
|
19
|
+
"--version", "-v",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
callback=show_version,
|
|
22
|
+
expose_value=False,
|
|
23
|
+
is_eager=True,
|
|
24
|
+
help="Show version and info banner.",
|
|
25
|
+
)
|
|
26
|
+
def cli() -> None:
|
|
27
|
+
"""CLDPM - Claude Project Manager.
|
|
28
|
+
|
|
29
|
+
An SDK and CLI for managing mono repos with multiple Claude Code projects.
|
|
30
|
+
Supports both shared components (reusable across projects) and local
|
|
31
|
+
components (project-specific).
|
|
32
|
+
|
|
33
|
+
\b
|
|
34
|
+
Component Types:
|
|
35
|
+
- Shared: Stored in shared/, symlinked to projects, reusable
|
|
36
|
+
- Local: Stored in .claude/, project-specific, committed directly
|
|
37
|
+
|
|
38
|
+
\b
|
|
39
|
+
Quick Start:
|
|
40
|
+
cldpm init my-monorepo # Create new mono repo
|
|
41
|
+
cldpm create project my-app # Create new project
|
|
42
|
+
cldpm add skill:common --to my-app # Add shared component
|
|
43
|
+
cldpm get my-app # View project info
|
|
44
|
+
cldpm clone my-app ./standalone # Export with all deps
|
|
45
|
+
cldpm sync --all # Restore symlinks after git clone
|
|
46
|
+
|
|
47
|
+
\b
|
|
48
|
+
Crafted by Transilience.ai
|
|
49
|
+
Authored by Aman Agarwal (https://github.com/amanagarwal041)
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Register commands
|
|
55
|
+
cli.add_command(init)
|
|
56
|
+
cli.add_command(create)
|
|
57
|
+
cli.add_command(add)
|
|
58
|
+
cli.add_command(remove)
|
|
59
|
+
cli.add_command(link)
|
|
60
|
+
cli.add_command(unlink)
|
|
61
|
+
cli.add_command(get)
|
|
62
|
+
cli.add_command(clone)
|
|
63
|
+
cli.add_command(sync)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@cli.command()
|
|
67
|
+
def info() -> None:
|
|
68
|
+
"""Show CLDPM information banner.
|
|
69
|
+
|
|
70
|
+
Displays version, quick start guide, and attribution.
|
|
71
|
+
"""
|
|
72
|
+
print_banner()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main() -> None:
|
|
76
|
+
"""Main entry point."""
|
|
77
|
+
cli()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""CLI commands for CLDPM."""
|
|
2
|
+
|
|
3
|
+
from .init import init
|
|
4
|
+
from .create import create
|
|
5
|
+
from .add import add
|
|
6
|
+
from .remove import remove
|
|
7
|
+
from .link import link, unlink
|
|
8
|
+
from .get import get
|
|
9
|
+
from .clone import clone
|
|
10
|
+
from .sync import sync
|
|
11
|
+
|
|
12
|
+
__all__ = ["init", "create", "add", "remove", "link", "unlink", "get", "clone", "sync"]
|
cldpm/commands/add.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Implementation of cldpm add command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from ..core.config import (
|
|
9
|
+
get_project_path,
|
|
10
|
+
load_cldpm_config,
|
|
11
|
+
load_project_config,
|
|
12
|
+
save_project_config,
|
|
13
|
+
)
|
|
14
|
+
from ..core.linker import add_component_link
|
|
15
|
+
from ..core.resolver import get_all_dependencies_for_component
|
|
16
|
+
from ..utils.fs import find_repo_root
|
|
17
|
+
from ..utils.output import console, print_error, print_success, print_warning
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_component(component: str, repo_root: Path) -> tuple[str, str]:
|
|
21
|
+
"""Parse a component specification into type and name.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
component: Component spec like "skill:my-skill" or just "my-skill".
|
|
25
|
+
repo_root: Path to the repo root for auto-detection.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Tuple of (component_type, component_name).
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ValueError: If component type cannot be determined.
|
|
32
|
+
"""
|
|
33
|
+
# Check for explicit type prefix
|
|
34
|
+
if ":" in component:
|
|
35
|
+
comp_type, comp_name = component.split(":", 1)
|
|
36
|
+
# Normalize type to plural form
|
|
37
|
+
type_map = {
|
|
38
|
+
"skill": "skills",
|
|
39
|
+
"skills": "skills",
|
|
40
|
+
"agent": "agents",
|
|
41
|
+
"agents": "agents",
|
|
42
|
+
"hook": "hooks",
|
|
43
|
+
"hooks": "hooks",
|
|
44
|
+
"rule": "rules",
|
|
45
|
+
"rules": "rules",
|
|
46
|
+
}
|
|
47
|
+
if comp_type not in type_map:
|
|
48
|
+
raise ValueError(f"Unknown component type: {comp_type}")
|
|
49
|
+
return type_map[comp_type], comp_name
|
|
50
|
+
|
|
51
|
+
# Auto-detect type by searching shared directories
|
|
52
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
53
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
54
|
+
|
|
55
|
+
for comp_type in ["skills", "agents", "hooks", "rules"]:
|
|
56
|
+
if (shared_dir / comp_type / component).exists():
|
|
57
|
+
return comp_type, component
|
|
58
|
+
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Component '{component}' not found. Use 'type:name' format or ensure it exists in shared/."
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def add_single_component(
|
|
65
|
+
comp_type: str,
|
|
66
|
+
comp_name: str,
|
|
67
|
+
project_path: Path,
|
|
68
|
+
repo_root: Path,
|
|
69
|
+
is_dependency: bool = False,
|
|
70
|
+
) -> bool:
|
|
71
|
+
"""Add a single component to a project.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
comp_type: Component type (skills, agents, hooks, rules).
|
|
75
|
+
comp_name: Component name.
|
|
76
|
+
project_path: Path to the project directory.
|
|
77
|
+
repo_root: Path to the repo root.
|
|
78
|
+
is_dependency: Whether this is being added as a dependency.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
True if added successfully, False if already exists or failed.
|
|
82
|
+
"""
|
|
83
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
84
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
85
|
+
component_path = shared_dir / comp_type / comp_name
|
|
86
|
+
|
|
87
|
+
if not component_path.exists():
|
|
88
|
+
if is_dependency:
|
|
89
|
+
console.print(f" [yellow]![/yellow] {comp_type}/{comp_name} (dependency not found)")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
# Load project config
|
|
93
|
+
project_config = load_project_config(project_path)
|
|
94
|
+
deps_list = getattr(project_config.dependencies, comp_type)
|
|
95
|
+
|
|
96
|
+
# Check if already added
|
|
97
|
+
if comp_name in deps_list:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Add to dependencies
|
|
101
|
+
deps_list.append(comp_name)
|
|
102
|
+
save_project_config(project_config, project_path)
|
|
103
|
+
|
|
104
|
+
# Create symlink
|
|
105
|
+
if add_component_link(project_path, comp_type, comp_name, repo_root):
|
|
106
|
+
if is_dependency:
|
|
107
|
+
console.print(f" [green]✓[/green] {comp_type}/{comp_name} (dependency)")
|
|
108
|
+
return True
|
|
109
|
+
else:
|
|
110
|
+
if is_dependency:
|
|
111
|
+
console.print(f" [yellow]![/yellow] {comp_type}/{comp_name} (symlink failed)")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@click.command()
|
|
116
|
+
@click.argument("component")
|
|
117
|
+
@click.option("--to", "-t", "project_name", required=True, help="Target project name")
|
|
118
|
+
@click.option("--no-deps", is_flag=True, help="Skip installing component dependencies")
|
|
119
|
+
def add(component: str, project_name: str, no_deps: bool) -> None:
|
|
120
|
+
"""Add a shared component to a project.
|
|
121
|
+
|
|
122
|
+
Creates a symlink from the project's .claude/ directory to the shared
|
|
123
|
+
component, and updates project.json dependencies. The symlink is
|
|
124
|
+
automatically gitignored; only the reference in project.json is committed.
|
|
125
|
+
|
|
126
|
+
Components can have dependencies on other components. By default, all
|
|
127
|
+
dependencies are also added. Use --no-deps to skip dependencies.
|
|
128
|
+
|
|
129
|
+
\b
|
|
130
|
+
COMPONENT format:
|
|
131
|
+
type:name - Explicit type (skill, agent, hook, rule)
|
|
132
|
+
name - Auto-detect type from shared/ directory
|
|
133
|
+
|
|
134
|
+
\b
|
|
135
|
+
Note: For project-specific (local) components, create them directly in
|
|
136
|
+
projects/<name>/.claude/<type>/ - no 'cldpm add' needed.
|
|
137
|
+
|
|
138
|
+
\b
|
|
139
|
+
Examples:
|
|
140
|
+
cldpm add skill:my-skill --to my-project
|
|
141
|
+
cldpm add agent:pentester --to my-project
|
|
142
|
+
cldpm add hook:pre-commit -t my-project
|
|
143
|
+
cldpm add my-skill --to my-project # Auto-detect type
|
|
144
|
+
cldpm add agent:security --to my-project --no-deps
|
|
145
|
+
"""
|
|
146
|
+
# Find repo root
|
|
147
|
+
repo_root = find_repo_root()
|
|
148
|
+
if repo_root is None:
|
|
149
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
150
|
+
raise SystemExit(1)
|
|
151
|
+
|
|
152
|
+
# Get project path
|
|
153
|
+
project_path = get_project_path(project_name, repo_root)
|
|
154
|
+
if project_path is None:
|
|
155
|
+
print_error(f"Project not found: {project_name}")
|
|
156
|
+
raise SystemExit(1)
|
|
157
|
+
|
|
158
|
+
# Parse component
|
|
159
|
+
try:
|
|
160
|
+
comp_type, comp_name = parse_component(component, repo_root)
|
|
161
|
+
except ValueError as e:
|
|
162
|
+
print_error(str(e))
|
|
163
|
+
raise SystemExit(1)
|
|
164
|
+
|
|
165
|
+
# Check if component exists
|
|
166
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
167
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
168
|
+
component_path = shared_dir / comp_type / comp_name
|
|
169
|
+
|
|
170
|
+
if not component_path.exists():
|
|
171
|
+
print_error(f"Component not found: {comp_type}/{comp_name}")
|
|
172
|
+
raise SystemExit(1)
|
|
173
|
+
|
|
174
|
+
# Load project config to check if already added
|
|
175
|
+
project_config = load_project_config(project_path)
|
|
176
|
+
deps_list = getattr(project_config.dependencies, comp_type)
|
|
177
|
+
|
|
178
|
+
if comp_name in deps_list:
|
|
179
|
+
print_warning(f"Component already in project: {comp_type}/{comp_name}")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Add the main component
|
|
183
|
+
deps_list.append(comp_name)
|
|
184
|
+
save_project_config(project_config, project_path)
|
|
185
|
+
|
|
186
|
+
# Create symlink
|
|
187
|
+
if add_component_link(project_path, comp_type, comp_name, repo_root):
|
|
188
|
+
print_success(f"Added {comp_type}/{comp_name} to {project_name}")
|
|
189
|
+
else:
|
|
190
|
+
print_warning(f"Added to config but failed to create symlink for {comp_type}/{comp_name}")
|
|
191
|
+
|
|
192
|
+
# Add dependencies if not skipped
|
|
193
|
+
if not no_deps:
|
|
194
|
+
all_deps = get_all_dependencies_for_component(comp_type, comp_name, repo_root)
|
|
195
|
+
deps_added = False
|
|
196
|
+
|
|
197
|
+
for dep_type, dep_names in all_deps.items():
|
|
198
|
+
for dep_name in dep_names:
|
|
199
|
+
if add_single_component(
|
|
200
|
+
dep_type, dep_name, project_path, repo_root, is_dependency=True
|
|
201
|
+
):
|
|
202
|
+
deps_added = True
|
|
203
|
+
|
|
204
|
+
if not deps_added and any(all_deps.values()):
|
|
205
|
+
# Dependencies exist but were already added
|
|
206
|
+
pass
|
cldpm/commands/clone.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Implementation of cldpm clone command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from ..core.config import load_cldpm_config, load_project_config
|
|
10
|
+
from ..core.resolver import resolve_project
|
|
11
|
+
from ..utils.fs import ensure_dir, find_repo_root
|
|
12
|
+
from ..utils.output import console, print_error, print_success, print_dir_tree
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.argument("project_name")
|
|
17
|
+
@click.argument("directory")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--include-shared",
|
|
20
|
+
is_flag=True,
|
|
21
|
+
help="Also copy the full shared/ directory structure",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--preserve-links",
|
|
25
|
+
is_flag=True,
|
|
26
|
+
help="Keep symlinks instead of copying (requires shared/ to exist)",
|
|
27
|
+
)
|
|
28
|
+
def clone(
|
|
29
|
+
project_name: str,
|
|
30
|
+
directory: str,
|
|
31
|
+
include_shared: bool,
|
|
32
|
+
preserve_links: bool,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Clone a project to a standalone directory with all dependencies.
|
|
35
|
+
|
|
36
|
+
Creates a complete, standalone copy of the project:
|
|
37
|
+
- Shared components: Resolved from symlinks, copied as actual files
|
|
38
|
+
- Local components: Copied directly
|
|
39
|
+
|
|
40
|
+
\b
|
|
41
|
+
Use cases:
|
|
42
|
+
- Export project to work outside the mono repo
|
|
43
|
+
- Share project with someone without the mono repo
|
|
44
|
+
- Create a snapshot with all dependencies
|
|
45
|
+
|
|
46
|
+
\b
|
|
47
|
+
Examples:
|
|
48
|
+
cldpm clone my-project ./standalone
|
|
49
|
+
cldpm clone my-project /path/to/output
|
|
50
|
+
cldpm clone my-project ./export --include-shared
|
|
51
|
+
cldpm clone my-project ./linked --preserve-links
|
|
52
|
+
"""
|
|
53
|
+
# Find repo root
|
|
54
|
+
repo_root = find_repo_root()
|
|
55
|
+
if repo_root is None:
|
|
56
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
57
|
+
raise SystemExit(1)
|
|
58
|
+
|
|
59
|
+
# Resolve project
|
|
60
|
+
try:
|
|
61
|
+
resolved = resolve_project(project_name, repo_root)
|
|
62
|
+
except FileNotFoundError as e:
|
|
63
|
+
print_error(str(e))
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
|
|
66
|
+
# Get source project path
|
|
67
|
+
source_path = Path(resolved["path"])
|
|
68
|
+
target_path = Path(directory).resolve()
|
|
69
|
+
|
|
70
|
+
# Check if target exists
|
|
71
|
+
if target_path.exists():
|
|
72
|
+
print_error(f"Target directory already exists: {target_path}")
|
|
73
|
+
raise SystemExit(1)
|
|
74
|
+
|
|
75
|
+
# Create target directory
|
|
76
|
+
ensure_dir(target_path)
|
|
77
|
+
|
|
78
|
+
# Copy project files (excluding .claude subdirectories that are symlinks)
|
|
79
|
+
for item in source_path.iterdir():
|
|
80
|
+
dest = target_path / item.name
|
|
81
|
+
|
|
82
|
+
if item.name == ".claude":
|
|
83
|
+
# Handle .claude directory specially
|
|
84
|
+
ensure_dir(dest)
|
|
85
|
+
for claude_item in item.iterdir():
|
|
86
|
+
claude_dest = dest / claude_item.name
|
|
87
|
+
|
|
88
|
+
if claude_item.name in ["skills", "agents", "hooks", "rules"]:
|
|
89
|
+
# Create directory, will be populated below
|
|
90
|
+
ensure_dir(claude_dest)
|
|
91
|
+
|
|
92
|
+
# Copy local (non-symlink) components directly
|
|
93
|
+
for comp_item in claude_item.iterdir():
|
|
94
|
+
if comp_item.name == ".gitignore":
|
|
95
|
+
continue # Skip .gitignore, will regenerate if needed
|
|
96
|
+
if not comp_item.is_symlink():
|
|
97
|
+
comp_dest = claude_dest / comp_item.name
|
|
98
|
+
if comp_item.is_dir():
|
|
99
|
+
shutil.copytree(comp_item, comp_dest)
|
|
100
|
+
else:
|
|
101
|
+
shutil.copy2(comp_item, comp_dest)
|
|
102
|
+
elif claude_item.is_file():
|
|
103
|
+
shutil.copy2(claude_item, claude_dest)
|
|
104
|
+
elif claude_item.is_dir():
|
|
105
|
+
shutil.copytree(claude_item, claude_dest)
|
|
106
|
+
elif item.is_dir():
|
|
107
|
+
shutil.copytree(item, dest, symlinks=preserve_links)
|
|
108
|
+
else:
|
|
109
|
+
shutil.copy2(item, dest)
|
|
110
|
+
|
|
111
|
+
# Copy shared dependencies
|
|
112
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
113
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
114
|
+
|
|
115
|
+
shared_counts = {}
|
|
116
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]:
|
|
117
|
+
count = 0
|
|
118
|
+
for component in resolved["shared"].get(dep_type, []):
|
|
119
|
+
comp_name = component["name"]
|
|
120
|
+
source_comp = shared_dir / dep_type / comp_name
|
|
121
|
+
target_comp = target_path / ".claude" / dep_type / comp_name
|
|
122
|
+
|
|
123
|
+
if source_comp.exists() and not target_comp.exists():
|
|
124
|
+
if preserve_links:
|
|
125
|
+
# Create symlink (will only work if shared/ is accessible)
|
|
126
|
+
target_comp.symlink_to(source_comp)
|
|
127
|
+
else:
|
|
128
|
+
# Copy actual files
|
|
129
|
+
if source_comp.is_dir():
|
|
130
|
+
shutil.copytree(source_comp, target_comp)
|
|
131
|
+
else:
|
|
132
|
+
shutil.copy2(source_comp, target_comp)
|
|
133
|
+
count += 1
|
|
134
|
+
shared_counts[dep_type] = count
|
|
135
|
+
|
|
136
|
+
# Count local components
|
|
137
|
+
local_counts = {
|
|
138
|
+
dep_type: len(resolved["local"].get(dep_type, []))
|
|
139
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Optionally copy full shared directory
|
|
143
|
+
if include_shared:
|
|
144
|
+
target_shared = target_path / "shared"
|
|
145
|
+
shutil.copytree(shared_dir, target_shared)
|
|
146
|
+
|
|
147
|
+
# Also copy cldpm.json for reference
|
|
148
|
+
shutil.copy2(repo_root / "cldpm.json", target_path / "cldpm.json")
|
|
149
|
+
|
|
150
|
+
# Update settings.json if it references hooks
|
|
151
|
+
settings_path = target_path / ".claude" / "settings.json"
|
|
152
|
+
if settings_path.exists():
|
|
153
|
+
try:
|
|
154
|
+
with open(settings_path) as f:
|
|
155
|
+
settings = json.load(f)
|
|
156
|
+
|
|
157
|
+
# Update any hook paths to be relative to the cloned project
|
|
158
|
+
# (This is a placeholder - actual hook path updates would depend on the hook format)
|
|
159
|
+
|
|
160
|
+
with open(settings_path, "w") as f:
|
|
161
|
+
json.dump(settings, f, indent=2)
|
|
162
|
+
f.write("\n")
|
|
163
|
+
except (json.JSONDecodeError, IOError):
|
|
164
|
+
pass # Ignore settings.json issues
|
|
165
|
+
|
|
166
|
+
print_success(f"Cloned {project_name} to {target_path}")
|
|
167
|
+
|
|
168
|
+
# Show what was copied
|
|
169
|
+
non_zero_shared = {k: v for k, v in shared_counts.items() if v > 0}
|
|
170
|
+
non_zero_local = {k: v for k, v in local_counts.items() if v > 0}
|
|
171
|
+
|
|
172
|
+
if non_zero_shared:
|
|
173
|
+
deps_str = ", ".join(f"{v} {k}" for k, v in non_zero_shared.items())
|
|
174
|
+
console.print(f" Shared: {deps_str}")
|
|
175
|
+
|
|
176
|
+
if non_zero_local:
|
|
177
|
+
deps_str = ", ".join(f"{v} {k}" for k, v in non_zero_local.items())
|
|
178
|
+
console.print(f" Local: {deps_str}")
|
|
179
|
+
|
|
180
|
+
if include_shared:
|
|
181
|
+
console.print(" Included: full shared/ directory")
|
|
182
|
+
|
|
183
|
+
console.print()
|
|
184
|
+
print_dir_tree(target_path, max_depth=3)
|