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/get.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Implementation of cldpm get command."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from ..core.config import load_cldpm_config
|
|
12
|
+
from ..core.resolver import resolve_project
|
|
13
|
+
from ..utils.fs import ensure_dir, find_repo_root
|
|
14
|
+
from ..utils.git import (
|
|
15
|
+
cleanup_temp_dir,
|
|
16
|
+
clone_to_temp,
|
|
17
|
+
get_github_token,
|
|
18
|
+
parse_repo_url,
|
|
19
|
+
)
|
|
20
|
+
from ..utils.output import console, print_error, print_success, print_tree, print_warning
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.command()
|
|
24
|
+
@click.argument("path_or_name")
|
|
25
|
+
@click.option(
|
|
26
|
+
"--format",
|
|
27
|
+
"-f",
|
|
28
|
+
"output_format",
|
|
29
|
+
type=click.Choice(["json", "tree"]),
|
|
30
|
+
default="tree",
|
|
31
|
+
help="Output format",
|
|
32
|
+
)
|
|
33
|
+
@click.option(
|
|
34
|
+
"--remote",
|
|
35
|
+
"-r",
|
|
36
|
+
"remote_url",
|
|
37
|
+
help="Git repository URL (uses GITHUB_TOKEN or GH_TOKEN env var for auth)",
|
|
38
|
+
)
|
|
39
|
+
@click.option(
|
|
40
|
+
"--download",
|
|
41
|
+
"-d",
|
|
42
|
+
is_flag=True,
|
|
43
|
+
help="Download/copy project with all dependencies to output directory",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--output",
|
|
47
|
+
"-o",
|
|
48
|
+
"output_dir",
|
|
49
|
+
help="Output directory for download (default: project name)",
|
|
50
|
+
)
|
|
51
|
+
def get(
|
|
52
|
+
path_or_name: str,
|
|
53
|
+
output_format: str,
|
|
54
|
+
remote_url: Optional[str],
|
|
55
|
+
download: bool,
|
|
56
|
+
output_dir: Optional[str],
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Get project info with all components (shared and local).
|
|
59
|
+
|
|
60
|
+
Shows both shared components (from shared/, symlinked) and local
|
|
61
|
+
components (project-specific, in .claude/).
|
|
62
|
+
|
|
63
|
+
\b
|
|
64
|
+
Output includes:
|
|
65
|
+
- Project config from project.json
|
|
66
|
+
- Shared components (symlinked from shared/)
|
|
67
|
+
- Local components (project-specific in .claude/)
|
|
68
|
+
|
|
69
|
+
\b
|
|
70
|
+
Download option (-d):
|
|
71
|
+
Works for both local and remote repos. Copies the project with
|
|
72
|
+
all dependencies resolved (shared components copied as files).
|
|
73
|
+
|
|
74
|
+
\b
|
|
75
|
+
Remote URL formats:
|
|
76
|
+
owner/repo - GitHub shorthand
|
|
77
|
+
github.com/owner/repo - Without https://
|
|
78
|
+
https://github.com/owner/repo - Full URL
|
|
79
|
+
|
|
80
|
+
\b
|
|
81
|
+
Environment variables for private repos:
|
|
82
|
+
GITHUB_TOKEN or GH_TOKEN
|
|
83
|
+
|
|
84
|
+
\b
|
|
85
|
+
Examples:
|
|
86
|
+
cldpm get my-project # Show project info
|
|
87
|
+
cldpm get my-project -f json # JSON output
|
|
88
|
+
cldpm get my-project -d # Download to ./my-project
|
|
89
|
+
cldpm get my-project -d -o ./copy # Download to ./copy
|
|
90
|
+
cldpm get my-project -r owner/repo # From remote
|
|
91
|
+
cldpm get my-project -r owner/repo -d # Download remote
|
|
92
|
+
"""
|
|
93
|
+
if remote_url:
|
|
94
|
+
_handle_remote_get(
|
|
95
|
+
path_or_name, output_format, remote_url, download, output_dir
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
_handle_local_get(path_or_name, output_format, download, output_dir)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _handle_local_get(
|
|
102
|
+
path_or_name: str,
|
|
103
|
+
output_format: str,
|
|
104
|
+
download: bool,
|
|
105
|
+
output_dir: Optional[str],
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Handle get command for local repositories."""
|
|
108
|
+
# Find repo root
|
|
109
|
+
repo_root = find_repo_root()
|
|
110
|
+
if repo_root is None:
|
|
111
|
+
print_error("Not in a CLDPM mono repo. Run 'cldpm init' first.")
|
|
112
|
+
raise SystemExit(1)
|
|
113
|
+
|
|
114
|
+
# Resolve project
|
|
115
|
+
try:
|
|
116
|
+
result = resolve_project(path_or_name, repo_root)
|
|
117
|
+
except FileNotFoundError as e:
|
|
118
|
+
print_error(str(e))
|
|
119
|
+
raise SystemExit(1)
|
|
120
|
+
|
|
121
|
+
# Output in requested format
|
|
122
|
+
if output_format == "json":
|
|
123
|
+
click.echo(json.dumps(result, indent=2))
|
|
124
|
+
else:
|
|
125
|
+
print_tree(result)
|
|
126
|
+
|
|
127
|
+
# Download if requested
|
|
128
|
+
if download:
|
|
129
|
+
_download_local_project(result, repo_root, output_dir)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _download_local_project(
|
|
133
|
+
resolved: dict,
|
|
134
|
+
repo_root: Path,
|
|
135
|
+
output_dir: Optional[str],
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Download/copy a local project with all dependencies resolved."""
|
|
138
|
+
source_path = Path(resolved["path"])
|
|
139
|
+
project_name = resolved["name"]
|
|
140
|
+
|
|
141
|
+
# Determine target directory
|
|
142
|
+
if output_dir:
|
|
143
|
+
target_path = Path(output_dir).resolve()
|
|
144
|
+
else:
|
|
145
|
+
target_path = Path.cwd() / project_name
|
|
146
|
+
|
|
147
|
+
if target_path.exists():
|
|
148
|
+
print_error(f"Target directory already exists: {target_path}")
|
|
149
|
+
raise SystemExit(1)
|
|
150
|
+
|
|
151
|
+
# Create target directory
|
|
152
|
+
ensure_dir(target_path)
|
|
153
|
+
|
|
154
|
+
# Load CLDPM config for shared directory path
|
|
155
|
+
cldpm_config = load_cldpm_config(repo_root)
|
|
156
|
+
shared_dir = repo_root / cldpm_config.shared_dir
|
|
157
|
+
|
|
158
|
+
# Copy project files
|
|
159
|
+
for item in source_path.iterdir():
|
|
160
|
+
dest = target_path / item.name
|
|
161
|
+
|
|
162
|
+
if item.name == ".claude":
|
|
163
|
+
# Handle .claude directory specially
|
|
164
|
+
ensure_dir(dest)
|
|
165
|
+
for claude_item in item.iterdir():
|
|
166
|
+
claude_dest = dest / claude_item.name
|
|
167
|
+
|
|
168
|
+
if claude_item.name in ["skills", "agents", "hooks", "rules"]:
|
|
169
|
+
# Create directory
|
|
170
|
+
ensure_dir(claude_dest)
|
|
171
|
+
|
|
172
|
+
# Copy local (non-symlink) components directly
|
|
173
|
+
for comp_item in claude_item.iterdir():
|
|
174
|
+
if comp_item.name == ".gitignore":
|
|
175
|
+
continue # Skip .gitignore
|
|
176
|
+
if not comp_item.is_symlink():
|
|
177
|
+
comp_dest = claude_dest / comp_item.name
|
|
178
|
+
if comp_item.is_dir():
|
|
179
|
+
shutil.copytree(comp_item, comp_dest)
|
|
180
|
+
else:
|
|
181
|
+
shutil.copy2(comp_item, comp_dest)
|
|
182
|
+
elif claude_item.is_file():
|
|
183
|
+
shutil.copy2(claude_item, claude_dest)
|
|
184
|
+
elif claude_item.is_dir():
|
|
185
|
+
shutil.copytree(claude_item, claude_dest)
|
|
186
|
+
elif item.is_dir():
|
|
187
|
+
shutil.copytree(item, dest, symlinks=False)
|
|
188
|
+
else:
|
|
189
|
+
shutil.copy2(item, dest)
|
|
190
|
+
|
|
191
|
+
# Copy shared dependencies (resolve symlinks to actual files)
|
|
192
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]:
|
|
193
|
+
for component in resolved["shared"].get(dep_type, []):
|
|
194
|
+
comp_name = component["name"]
|
|
195
|
+
source_comp = shared_dir / dep_type / comp_name
|
|
196
|
+
target_comp = target_path / ".claude" / dep_type / comp_name
|
|
197
|
+
|
|
198
|
+
if source_comp.exists() and not target_comp.exists():
|
|
199
|
+
if source_comp.is_dir():
|
|
200
|
+
shutil.copytree(source_comp, target_comp)
|
|
201
|
+
else:
|
|
202
|
+
shutil.copy2(source_comp, target_comp)
|
|
203
|
+
|
|
204
|
+
# Count what was copied
|
|
205
|
+
shared_counts = {
|
|
206
|
+
dep_type: len(resolved["shared"].get(dep_type, []))
|
|
207
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]
|
|
208
|
+
}
|
|
209
|
+
local_counts = {
|
|
210
|
+
dep_type: len(resolved["local"].get(dep_type, []))
|
|
211
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
print_success(f"Downloaded to {target_path}")
|
|
215
|
+
|
|
216
|
+
non_zero_shared = {k: v for k, v in shared_counts.items() if v > 0}
|
|
217
|
+
non_zero_local = {k: v for k, v in local_counts.items() if v > 0}
|
|
218
|
+
|
|
219
|
+
if non_zero_shared:
|
|
220
|
+
deps_str = ", ".join(f"{v} {k}" for k, v in non_zero_shared.items())
|
|
221
|
+
console.print(f" Shared: {deps_str}")
|
|
222
|
+
|
|
223
|
+
if non_zero_local:
|
|
224
|
+
deps_str = ", ".join(f"{v} {k}" for k, v in non_zero_local.items())
|
|
225
|
+
console.print(f" Local: {deps_str}")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _handle_remote_get(
|
|
229
|
+
path_or_name: str,
|
|
230
|
+
output_format: str,
|
|
231
|
+
remote_url: str,
|
|
232
|
+
download: bool,
|
|
233
|
+
output_dir: Optional[str],
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Handle get command for remote repositories."""
|
|
236
|
+
# Get GitHub token
|
|
237
|
+
token = get_github_token()
|
|
238
|
+
if not token:
|
|
239
|
+
print_warning(
|
|
240
|
+
"No GITHUB_TOKEN or GH_TOKEN found. Private repos may not be accessible."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Parse the remote URL
|
|
244
|
+
try:
|
|
245
|
+
repo_url, _subpath, branch = parse_repo_url(remote_url)
|
|
246
|
+
except ValueError as e:
|
|
247
|
+
print_error(str(e))
|
|
248
|
+
raise SystemExit(1)
|
|
249
|
+
|
|
250
|
+
temp_dir = None
|
|
251
|
+
try:
|
|
252
|
+
# Clone to temporary directory
|
|
253
|
+
console.print(f"[dim]Cloning {repo_url}...[/dim]")
|
|
254
|
+
temp_dir = clone_to_temp(repo_url, branch, token)
|
|
255
|
+
|
|
256
|
+
# Check if it's a valid CLDPM repo
|
|
257
|
+
if not (temp_dir / "cldpm.json").exists():
|
|
258
|
+
print_error("Remote repository is not a CLDPM mono repo (no cldpm.json found)")
|
|
259
|
+
raise SystemExit(1)
|
|
260
|
+
|
|
261
|
+
# Resolve the project
|
|
262
|
+
try:
|
|
263
|
+
result = resolve_project(path_or_name, temp_dir)
|
|
264
|
+
except FileNotFoundError as e:
|
|
265
|
+
print_error(str(e))
|
|
266
|
+
raise SystemExit(1)
|
|
267
|
+
|
|
268
|
+
# Add remote info to result
|
|
269
|
+
result["remote"] = {
|
|
270
|
+
"url": remote_url,
|
|
271
|
+
"repo_url": repo_url,
|
|
272
|
+
"branch": branch,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# Output the result
|
|
276
|
+
if output_format == "json":
|
|
277
|
+
click.echo(json.dumps(result, indent=2))
|
|
278
|
+
else:
|
|
279
|
+
print_tree(result)
|
|
280
|
+
console.print(f"\n[dim]Source: {remote_url}[/dim]")
|
|
281
|
+
|
|
282
|
+
# Download if requested
|
|
283
|
+
if download:
|
|
284
|
+
_download_remote_project(result, temp_dir, output_dir, repo_url)
|
|
285
|
+
|
|
286
|
+
except subprocess.CalledProcessError as e:
|
|
287
|
+
error_msg = e.stderr if e.stderr else str(e)
|
|
288
|
+
if "Authentication failed" in error_msg or "could not read" in error_msg:
|
|
289
|
+
print_error(
|
|
290
|
+
"Authentication failed. Set GITHUB_TOKEN or GH_TOKEN environment variable."
|
|
291
|
+
)
|
|
292
|
+
else:
|
|
293
|
+
print_error(f"Git error: {error_msg}")
|
|
294
|
+
raise SystemExit(1)
|
|
295
|
+
finally:
|
|
296
|
+
# Clean up temp directory if not downloading
|
|
297
|
+
if temp_dir and not download:
|
|
298
|
+
cleanup_temp_dir(temp_dir)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _download_remote_project(
|
|
302
|
+
resolved: dict,
|
|
303
|
+
temp_dir: Path,
|
|
304
|
+
output_dir: Optional[str],
|
|
305
|
+
repo_url: str,
|
|
306
|
+
) -> None:
|
|
307
|
+
"""Download a remote project with all dependencies resolved."""
|
|
308
|
+
project_name = resolved["name"]
|
|
309
|
+
|
|
310
|
+
# Determine target directory
|
|
311
|
+
if output_dir:
|
|
312
|
+
target = Path(output_dir).resolve()
|
|
313
|
+
else:
|
|
314
|
+
target = Path.cwd() / project_name
|
|
315
|
+
|
|
316
|
+
if target.exists():
|
|
317
|
+
print_error(f"Target directory already exists: {target}")
|
|
318
|
+
raise SystemExit(1)
|
|
319
|
+
|
|
320
|
+
# For remote, we copy the resolved project (similar to local download)
|
|
321
|
+
# but from the temp directory
|
|
322
|
+
cldpm_config = load_cldpm_config(temp_dir)
|
|
323
|
+
shared_dir = temp_dir / cldpm_config.shared_dir
|
|
324
|
+
source_path = Path(resolved["path"])
|
|
325
|
+
|
|
326
|
+
# Create target directory
|
|
327
|
+
ensure_dir(target)
|
|
328
|
+
|
|
329
|
+
# Copy project files
|
|
330
|
+
for item in source_path.iterdir():
|
|
331
|
+
dest = target / item.name
|
|
332
|
+
|
|
333
|
+
if item.name == ".claude":
|
|
334
|
+
ensure_dir(dest)
|
|
335
|
+
for claude_item in item.iterdir():
|
|
336
|
+
claude_dest = dest / claude_item.name
|
|
337
|
+
|
|
338
|
+
if claude_item.name in ["skills", "agents", "hooks", "rules"]:
|
|
339
|
+
ensure_dir(claude_dest)
|
|
340
|
+
for comp_item in claude_item.iterdir():
|
|
341
|
+
if comp_item.name == ".gitignore":
|
|
342
|
+
continue
|
|
343
|
+
if not comp_item.is_symlink():
|
|
344
|
+
comp_dest = claude_dest / comp_item.name
|
|
345
|
+
if comp_item.is_dir():
|
|
346
|
+
shutil.copytree(comp_item, comp_dest)
|
|
347
|
+
else:
|
|
348
|
+
shutil.copy2(comp_item, comp_dest)
|
|
349
|
+
elif claude_item.is_file():
|
|
350
|
+
shutil.copy2(claude_item, claude_dest)
|
|
351
|
+
elif claude_item.is_dir():
|
|
352
|
+
shutil.copytree(claude_item, claude_dest)
|
|
353
|
+
elif item.is_dir():
|
|
354
|
+
shutil.copytree(item, dest, symlinks=False)
|
|
355
|
+
else:
|
|
356
|
+
shutil.copy2(item, dest)
|
|
357
|
+
|
|
358
|
+
# Copy shared dependencies
|
|
359
|
+
for dep_type in ["skills", "agents", "hooks", "rules"]:
|
|
360
|
+
for component in resolved["shared"].get(dep_type, []):
|
|
361
|
+
comp_name = component["name"]
|
|
362
|
+
source_comp = shared_dir / dep_type / comp_name
|
|
363
|
+
target_comp = target / ".claude" / dep_type / comp_name
|
|
364
|
+
|
|
365
|
+
if source_comp.exists() and not target_comp.exists():
|
|
366
|
+
if source_comp.is_dir():
|
|
367
|
+
shutil.copytree(source_comp, target_comp)
|
|
368
|
+
else:
|
|
369
|
+
shutil.copy2(source_comp, target_comp)
|
|
370
|
+
|
|
371
|
+
# Clean up temp directory
|
|
372
|
+
cleanup_temp_dir(temp_dir)
|
|
373
|
+
|
|
374
|
+
print_success(f"Downloaded to {target}")
|
|
375
|
+
console.print(f" [dim]Source: {repo_url}[/dim]")
|
cldpm/commands/init.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Implementation of cldpm init command."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
from jinja2 import Environment, PackageLoader
|
|
8
|
+
|
|
9
|
+
from ..schemas import CldpmConfig, ProjectConfig, ProjectDependencies
|
|
10
|
+
from ..core.config import save_cldpm_config, save_project_config
|
|
11
|
+
from ..utils.fs import ensure_dir
|
|
12
|
+
from ..utils.output import print_success, print_error, print_warning, print_dir_tree, console
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.argument("directory", required=False, default=".")
|
|
17
|
+
@click.option("--name", "-n", help="Name for the mono repo")
|
|
18
|
+
@click.option(
|
|
19
|
+
"--existing",
|
|
20
|
+
"-e",
|
|
21
|
+
is_flag=True,
|
|
22
|
+
help="Initialize in an existing directory without overwriting files",
|
|
23
|
+
)
|
|
24
|
+
@click.option(
|
|
25
|
+
"--adopt-projects",
|
|
26
|
+
"-a",
|
|
27
|
+
"adopt_projects",
|
|
28
|
+
help="Adopt existing directories as projects (comma-separated paths or 'auto' to detect)",
|
|
29
|
+
)
|
|
30
|
+
@click.option(
|
|
31
|
+
"--projects-dir",
|
|
32
|
+
"-p",
|
|
33
|
+
"projects_dir",
|
|
34
|
+
default="projects",
|
|
35
|
+
help="Directory for projects (default: projects)",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--shared-dir",
|
|
39
|
+
"-s",
|
|
40
|
+
"shared_dir",
|
|
41
|
+
default="shared",
|
|
42
|
+
help="Directory for shared components (default: shared)",
|
|
43
|
+
)
|
|
44
|
+
def init(
|
|
45
|
+
directory: str,
|
|
46
|
+
name: Optional[str],
|
|
47
|
+
existing: bool,
|
|
48
|
+
adopt_projects: Optional[str],
|
|
49
|
+
projects_dir: str,
|
|
50
|
+
shared_dir: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Initialize a new CLDPM mono repo.
|
|
53
|
+
|
|
54
|
+
Creates the directory structure and configuration files for managing
|
|
55
|
+
multiple Claude Code projects with shared and local components.
|
|
56
|
+
|
|
57
|
+
\b
|
|
58
|
+
Structure created:
|
|
59
|
+
shared/ - Shared components (skills, agents, hooks, rules)
|
|
60
|
+
projects/ - Individual projects
|
|
61
|
+
cldpm.json - Mono repo configuration
|
|
62
|
+
CLAUDE.md - Root instructions
|
|
63
|
+
|
|
64
|
+
\b
|
|
65
|
+
Examples:
|
|
66
|
+
cldpm init # Current directory
|
|
67
|
+
cldpm init my-monorepo # New directory
|
|
68
|
+
cldpm init --name "My Repo" # Custom name
|
|
69
|
+
cldpm init -e # Existing repo, don't overwrite
|
|
70
|
+
cldpm init -e --adopt-projects auto # Auto-detect existing projects
|
|
71
|
+
cldpm init -e -a "app,api" # Adopt specific projects
|
|
72
|
+
cldpm init -p src -s common # Custom directory names
|
|
73
|
+
"""
|
|
74
|
+
# Resolve directory
|
|
75
|
+
repo_root = Path(directory).resolve()
|
|
76
|
+
|
|
77
|
+
# Check if already initialized
|
|
78
|
+
if (repo_root / "cldpm.json").exists():
|
|
79
|
+
if existing:
|
|
80
|
+
print_warning(f"CLDPM repo already exists at {repo_root}, updating...")
|
|
81
|
+
else:
|
|
82
|
+
print_error(f"CLDPM repo already exists at {repo_root}")
|
|
83
|
+
raise SystemExit(1)
|
|
84
|
+
|
|
85
|
+
# Check if directory exists and has content (for non-existing mode)
|
|
86
|
+
if not existing and repo_root.exists() and any(repo_root.iterdir()):
|
|
87
|
+
print_error(
|
|
88
|
+
f"Directory {repo_root} is not empty. Use --existing to initialize an existing repo."
|
|
89
|
+
)
|
|
90
|
+
raise SystemExit(1)
|
|
91
|
+
|
|
92
|
+
# Determine repo name
|
|
93
|
+
if name is None:
|
|
94
|
+
name = repo_root.name
|
|
95
|
+
|
|
96
|
+
# Create directory if needed
|
|
97
|
+
ensure_dir(repo_root)
|
|
98
|
+
|
|
99
|
+
# Create config
|
|
100
|
+
config = CldpmConfig(
|
|
101
|
+
name=name,
|
|
102
|
+
projectsDir=projects_dir,
|
|
103
|
+
sharedDir=shared_dir,
|
|
104
|
+
)
|
|
105
|
+
save_cldpm_config(config, repo_root)
|
|
106
|
+
|
|
107
|
+
# Create directory structure
|
|
108
|
+
dirs_to_create = [
|
|
109
|
+
f"{shared_dir}/skills",
|
|
110
|
+
f"{shared_dir}/agents",
|
|
111
|
+
f"{shared_dir}/hooks",
|
|
112
|
+
f"{shared_dir}/rules",
|
|
113
|
+
projects_dir,
|
|
114
|
+
".cldpm/templates",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
for dir_path in dirs_to_create:
|
|
118
|
+
ensure_dir(repo_root / dir_path)
|
|
119
|
+
|
|
120
|
+
# Create templates using Jinja2
|
|
121
|
+
env = Environment(loader=PackageLoader("cldpm", "templates"))
|
|
122
|
+
|
|
123
|
+
# Create root CLAUDE.md (only if it doesn't exist or not in existing mode)
|
|
124
|
+
claude_md_path = repo_root / "CLAUDE.md"
|
|
125
|
+
if not existing or not claude_md_path.exists():
|
|
126
|
+
template = env.get_template("ROOT_CLAUDE.md.j2")
|
|
127
|
+
claude_md = template.render(repo_name=name)
|
|
128
|
+
claude_md_path.write_text(claude_md)
|
|
129
|
+
|
|
130
|
+
# Create/update .gitignore
|
|
131
|
+
gitignore_path = repo_root / ".gitignore"
|
|
132
|
+
if not existing or not gitignore_path.exists():
|
|
133
|
+
template = env.get_template("gitignore.j2")
|
|
134
|
+
gitignore = template.render()
|
|
135
|
+
gitignore_path.write_text(gitignore)
|
|
136
|
+
elif existing and gitignore_path.exists():
|
|
137
|
+
# Append CLDPM-specific entries if not already present
|
|
138
|
+
_update_gitignore(gitignore_path, projects_dir)
|
|
139
|
+
|
|
140
|
+
print_success(f"Initialized CLDPM mono repo: {name}")
|
|
141
|
+
|
|
142
|
+
# Adopt existing projects if requested
|
|
143
|
+
if adopt_projects:
|
|
144
|
+
adopted = _adopt_projects(repo_root, adopt_projects, projects_dir, env)
|
|
145
|
+
if adopted:
|
|
146
|
+
console.print(f" Adopted {len(adopted)} project(s): {', '.join(adopted)}")
|
|
147
|
+
|
|
148
|
+
console.print()
|
|
149
|
+
print_dir_tree(repo_root, max_depth=2)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _update_gitignore(gitignore_path: Path, projects_dir: str) -> None:
|
|
153
|
+
"""Update existing .gitignore with CLDPM-specific note."""
|
|
154
|
+
existing_content = gitignore_path.read_text()
|
|
155
|
+
|
|
156
|
+
# Check if CLDPM note already exists
|
|
157
|
+
if "CLDPM Note" in existing_content or "CLDPM shared components" in existing_content:
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
cldpm_note = """
|
|
161
|
+
# CLDPM Note: Shared component symlinks are managed per-directory
|
|
162
|
+
# Each .claude/{skills,agents,hooks,rules}/ has its own .gitignore
|
|
163
|
+
# that only ignores symlinked shared components.
|
|
164
|
+
# Project-specific components in those directories ARE committed.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
# Append CLDPM note
|
|
168
|
+
with open(gitignore_path, "a") as f:
|
|
169
|
+
f.write(cldpm_note)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _adopt_projects(
|
|
173
|
+
repo_root: Path,
|
|
174
|
+
adopt_projects: str,
|
|
175
|
+
projects_dir: str,
|
|
176
|
+
env: Environment,
|
|
177
|
+
) -> list[str]:
|
|
178
|
+
"""Adopt existing directories as CLDPM projects.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
repo_root: Path to the repo root.
|
|
182
|
+
adopt_projects: Comma-separated project paths or 'auto'.
|
|
183
|
+
projects_dir: Directory containing projects.
|
|
184
|
+
env: Jinja2 environment for templates.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
List of adopted project names.
|
|
188
|
+
"""
|
|
189
|
+
adopted = []
|
|
190
|
+
projects_path = repo_root / projects_dir
|
|
191
|
+
|
|
192
|
+
if adopt_projects.lower() == "auto":
|
|
193
|
+
# Auto-detect: look for directories in projects_dir or repo root
|
|
194
|
+
candidates = []
|
|
195
|
+
|
|
196
|
+
# Check projects directory
|
|
197
|
+
if projects_path.exists():
|
|
198
|
+
candidates.extend(
|
|
199
|
+
[d for d in projects_path.iterdir() if d.is_dir() and not d.name.startswith(".")]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# If no projects_dir or it's empty, check repo root for potential projects
|
|
203
|
+
if not candidates:
|
|
204
|
+
for item in repo_root.iterdir():
|
|
205
|
+
if (
|
|
206
|
+
item.is_dir()
|
|
207
|
+
and not item.name.startswith(".")
|
|
208
|
+
and item.name not in ["shared", "projects", ".cldpm", "node_modules", "__pycache__", "venv", ".venv"]
|
|
209
|
+
):
|
|
210
|
+
# Check if it looks like a project (has code or package files)
|
|
211
|
+
if _looks_like_project(item):
|
|
212
|
+
candidates.append(item)
|
|
213
|
+
else:
|
|
214
|
+
# Explicit project paths
|
|
215
|
+
candidates = []
|
|
216
|
+
for proj_path in adopt_projects.split(","):
|
|
217
|
+
proj_path = proj_path.strip()
|
|
218
|
+
if not proj_path:
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
full_path = repo_root / proj_path
|
|
222
|
+
if full_path.exists() and full_path.is_dir():
|
|
223
|
+
candidates.append(full_path)
|
|
224
|
+
else:
|
|
225
|
+
print_warning(f"Project path not found: {proj_path}")
|
|
226
|
+
|
|
227
|
+
# Adopt each candidate
|
|
228
|
+
for candidate in candidates:
|
|
229
|
+
project_name = candidate.name
|
|
230
|
+
|
|
231
|
+
# Skip if already has project.json
|
|
232
|
+
if (candidate / "project.json").exists():
|
|
233
|
+
print_warning(f"Skipping {project_name}: already has project.json")
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
# Create project.json
|
|
237
|
+
project_config = ProjectConfig(
|
|
238
|
+
name=project_name,
|
|
239
|
+
description=f"Adopted project: {project_name}",
|
|
240
|
+
dependencies=ProjectDependencies(),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# If project is not in projects_dir, we need to handle it
|
|
244
|
+
if candidate.parent != projects_path:
|
|
245
|
+
# Move to projects directory or create a reference
|
|
246
|
+
target_path = projects_path / project_name
|
|
247
|
+
if not target_path.exists():
|
|
248
|
+
# Create the project in projects_dir with a reference
|
|
249
|
+
ensure_dir(target_path)
|
|
250
|
+
save_project_config(project_config, target_path)
|
|
251
|
+
_setup_project_structure(target_path, env, project_name)
|
|
252
|
+
|
|
253
|
+
# Note: The original directory stays in place
|
|
254
|
+
# User may want to move files manually or set up differently
|
|
255
|
+
print_warning(
|
|
256
|
+
f"Created project entry for {project_name}. "
|
|
257
|
+
f"Original files remain at {candidate.relative_to(repo_root)}"
|
|
258
|
+
)
|
|
259
|
+
adopted.append(project_name)
|
|
260
|
+
else:
|
|
261
|
+
# Project is already in projects_dir
|
|
262
|
+
save_project_config(project_config, candidate)
|
|
263
|
+
_setup_project_structure(candidate, env, project_name)
|
|
264
|
+
adopted.append(project_name)
|
|
265
|
+
|
|
266
|
+
return adopted
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _looks_like_project(path: Path) -> bool:
|
|
270
|
+
"""Check if a directory looks like a project.
|
|
271
|
+
|
|
272
|
+
Looks for common project indicators like package files, source directories, etc.
|
|
273
|
+
"""
|
|
274
|
+
indicators = [
|
|
275
|
+
"package.json",
|
|
276
|
+
"pyproject.toml",
|
|
277
|
+
"setup.py",
|
|
278
|
+
"Cargo.toml",
|
|
279
|
+
"go.mod",
|
|
280
|
+
"pom.xml",
|
|
281
|
+
"build.gradle",
|
|
282
|
+
"Makefile",
|
|
283
|
+
"CMakeLists.txt",
|
|
284
|
+
"src",
|
|
285
|
+
"lib",
|
|
286
|
+
"app",
|
|
287
|
+
"main.py",
|
|
288
|
+
"index.js",
|
|
289
|
+
"index.ts",
|
|
290
|
+
"CLAUDE.md",
|
|
291
|
+
".claude",
|
|
292
|
+
]
|
|
293
|
+
|
|
294
|
+
for indicator in indicators:
|
|
295
|
+
if (path / indicator).exists():
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
return False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _setup_project_structure(
|
|
302
|
+
project_path: Path,
|
|
303
|
+
env: Environment,
|
|
304
|
+
project_name: str,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Set up the standard CLDPM project structure."""
|
|
307
|
+
# Create .claude directory structure
|
|
308
|
+
claude_dir = project_path / ".claude"
|
|
309
|
+
ensure_dir(claude_dir)
|
|
310
|
+
ensure_dir(claude_dir / "skills")
|
|
311
|
+
ensure_dir(claude_dir / "agents")
|
|
312
|
+
ensure_dir(claude_dir / "hooks")
|
|
313
|
+
ensure_dir(claude_dir / "rules")
|
|
314
|
+
|
|
315
|
+
# Create settings.json if it doesn't exist
|
|
316
|
+
settings_path = claude_dir / "settings.json"
|
|
317
|
+
if not settings_path.exists():
|
|
318
|
+
settings_path.write_text("{}\n")
|
|
319
|
+
|
|
320
|
+
# Create outputs directory
|
|
321
|
+
ensure_dir(project_path / "outputs")
|
|
322
|
+
|
|
323
|
+
# Create CLAUDE.md from template if it doesn't exist
|
|
324
|
+
claude_md_path = project_path / "CLAUDE.md"
|
|
325
|
+
if not claude_md_path.exists():
|
|
326
|
+
template = env.get_template("CLAUDE.md.j2")
|
|
327
|
+
claude_md = template.render(
|
|
328
|
+
project_name=project_name,
|
|
329
|
+
description="",
|
|
330
|
+
)
|
|
331
|
+
claude_md_path.write_text(claude_md)
|