Skip to content

Commit 7fb9c16

Browse files
fix: remote get dependencies (#19)
1 parent 956a849 commit 7fb9c16

7 files changed

Lines changed: 137 additions & 65 deletions

File tree

python/cldpm/commands/get.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88

99
import click
1010

11+
12+
def _find_component_path(base_dir: Path, dep_type: str, dep_name: str) -> Optional[Path]:
13+
"""Find a component path, checking both directory and file variants.
14+
15+
Components can be directories (e.g., shared/skills/logging/) or
16+
files (e.g., shared/skills/new-skill.md). This function checks
17+
the exact path first, then looks for file matches with extensions.
18+
"""
19+
exact = base_dir / dep_type / dep_name
20+
if exact.exists():
21+
return exact
22+
# Look for file with extension (e.g., new-skill.md)
23+
parent = base_dir / dep_type
24+
if parent.exists():
25+
for item in parent.iterdir():
26+
if item.stem == dep_name and item.is_file():
27+
return item
28+
return None
29+
1130
from ..core.config import load_cldpm_config
1231
from ..core.resolver import resolve_project
1332
from ..utils.fs import ensure_dir, find_repo_root
@@ -207,10 +226,12 @@ def _download_local_project(
207226
for dep_type in ["skills", "agents", "hooks", "rules"]:
208227
for component in resolved["shared"].get(dep_type, []):
209228
comp_name = component["name"]
210-
source_comp = shared_dir / dep_type / comp_name
211-
target_comp = target_path / ".claude" / dep_type / comp_name
229+
source_comp = _find_component_path(shared_dir, dep_type, comp_name)
230+
if source_comp is None:
231+
continue
232+
target_comp = target_path / ".claude" / dep_type / source_comp.name
212233

213-
if source_comp.exists() and not target_comp.exists():
234+
if not target_comp.exists():
214235
if source_comp.is_dir():
215236
shutil.copytree(source_comp, target_comp)
216237
else:
@@ -334,11 +355,15 @@ def _handle_remote_get_sparse(
334355
cleanup_temp_dir(temp_project)
335356

336357
# Build path list for final sparse clone
358+
# Include both directory and file patterns for each dependency
359+
# since components can be directories (e.g., shared/skills/logging/)
360+
# or files (e.g., shared/skills/new-skill.md)
337361
all_paths = [project_path]
338362
dependencies = project_config.get("dependencies", {})
339363
for dep_type in ["skills", "agents", "hooks", "rules"]:
340364
for dep_name in dependencies.get(dep_type, []):
341365
all_paths.append(f"{shared_dir}/{dep_type}/{dep_name}")
366+
all_paths.append(f"{shared_dir}/{dep_type}/{dep_name}.*")
342367

343368
# Phase 3: Download everything needed
344369
console.print(f"[dim]Downloading project and dependencies...[/dim]")
@@ -478,8 +503,10 @@ def _build_sparse_result(
478503
for dep_type in ["skills", "agents", "hooks", "rules"]:
479504
result["shared"][dep_type] = []
480505
for dep_name in dependencies.get(dep_type, []):
481-
source_comp = temp_dir / shared_dir / dep_type / dep_name
482-
if source_comp.exists():
506+
source_comp = _find_component_path(
507+
temp_dir / shared_dir, dep_type, dep_name
508+
)
509+
if source_comp:
483510
# Get list of files in the component
484511
if source_comp.is_dir():
485512
files = [f.name for f in source_comp.iterdir() if f.is_file()]
@@ -488,7 +515,7 @@ def _build_sparse_result(
488515
result["shared"][dep_type].append({
489516
"name": dep_name,
490517
"type": "shared",
491-
"sourcePath": f"{shared_dir}/{dep_type}/{dep_name}",
518+
"sourcePath": f"{shared_dir}/{dep_type}/{source_comp.name}",
492519
"files": files,
493520
})
494521

@@ -576,10 +603,14 @@ def _download_sparse_project(
576603
# Place shared components directly in .claude/<type>/<name>/
577604
for dep_type in ["skills", "agents", "hooks", "rules"]:
578605
for dep_name in dependencies.get(dep_type, []):
579-
source_comp = temp_dir / shared_dir / dep_type / dep_name
580-
target_comp = target / ".claude" / dep_type / dep_name
606+
source_comp = _find_component_path(
607+
temp_dir / Path(shared_dir), dep_type, dep_name
608+
)
609+
if source_comp is None:
610+
continue
611+
target_comp = target / ".claude" / dep_type / source_comp.name
581612

582-
if source_comp.exists() and not target_comp.exists():
613+
if not target_comp.exists():
583614
ensure_dir(target_comp.parent)
584615
if source_comp.is_dir():
585616
shutil.copytree(source_comp, target_comp)
@@ -678,10 +709,12 @@ def _download_remote_project(
678709
for dep_type in ["skills", "agents", "hooks", "rules"]:
679710
for component in resolved["shared"].get(dep_type, []):
680711
comp_name = component["name"]
681-
source_comp = shared_dir / dep_type / comp_name
682-
target_comp = target / ".claude" / dep_type / comp_name
712+
source_comp = _find_component_path(shared_dir, dep_type, comp_name)
713+
if source_comp is None:
714+
continue
715+
target_comp = target / ".claude" / dep_type / source_comp.name
683716

684-
if source_comp.exists() and not target_comp.exists():
717+
if not target_comp.exists():
685718
if source_comp.is_dir():
686719
shutil.copytree(source_comp, target_comp)
687720
else:

python/cldpm/utils/git.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -256,17 +256,24 @@ def sparse_clone_paths(
256256
sparse_cmd, cwd=temp_clone, check=True, capture_output=True, env=env
257257
)
258258

259-
# Step 3: Copy files to target (excluding .git)
259+
# Step 3: Copy sparse checkout to target (excluding .git)
260+
# Skip broken symlinks during copy (they can't be resolved)
261+
def _ignore_broken_symlinks_and_git(directory, contents):
262+
ignored = []
263+
for item in contents:
264+
if item == ".git":
265+
ignored.append(item)
266+
continue
267+
item_path = Path(directory) / item
268+
if item_path.is_symlink() and not item_path.resolve().exists():
269+
ignored.append(item)
270+
return ignored
271+
260272
target_dir.mkdir(parents=True, exist_ok=True)
261-
for path in paths:
262-
src = temp_clone / path
263-
if src.exists():
264-
dst = target_dir / path
265-
dst.parent.mkdir(parents=True, exist_ok=True)
266-
if src.is_dir():
267-
shutil.copytree(src, dst, dirs_exist_ok=True)
268-
else:
269-
shutil.copy2(src, dst)
273+
shutil.copytree(
274+
temp_clone, target_dir, dirs_exist_ok=True,
275+
ignore=_ignore_broken_symlinks_and_git,
276+
)
270277
finally:
271278
shutil.rmtree(temp_clone, ignore_errors=True)
272279

python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cldpm"
3-
version = "0.1.6"
3+
version = "0.1.7"
44
description = "Claude Project Manager - SDK and CLI for mono repo management with Claude Code projects"
55
readme = "README.md"
66
license = "MIT"

typescript/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

typescript/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cldpm",
3-
"version": "0.1.6",
3+
"version": "0.1.7",
44
"description": "Claude Project Manager - SDK and CLI for mono repo management with Claude Code projects",
55
"type": "module",
66
"main": "./dist/index.js",

typescript/src/commands/get.ts

Lines changed: 54 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,34 @@ import {
2525
import type { ResolvedProject } from "../core/resolver.js";
2626
import type { ResolvedComponent } from "../schemas/index.js";
2727

28+
/**
29+
* Find a component path, checking both directory and file variants.
30+
* Components can be directories (e.g., shared/skills/logging/) or
31+
* files (e.g., shared/skills/new-skill.md).
32+
*/
33+
function findComponentPath(baseDir: string, depType: string, depName: string): string | null {
34+
const exact = path.join(baseDir, depType, depName);
35+
try {
36+
fsSync.accessSync(exact);
37+
return exact;
38+
} catch {
39+
// Look for file with extension
40+
const parent = path.join(baseDir, depType);
41+
try {
42+
const entries = fsSync.readdirSync(parent);
43+
for (const entry of entries) {
44+
const parsed = path.parse(entry);
45+
if (parsed.name === depName && fsSync.statSync(path.join(parent, entry)).isFile()) {
46+
return path.join(parent, entry);
47+
}
48+
}
49+
} catch {
50+
// parent doesn't exist
51+
}
52+
}
53+
return null;
54+
}
55+
2856
/**
2957
* Copy a directory recursively, resolving symlinks to actual files
3058
*/
@@ -265,6 +293,7 @@ async function handleRemoteGetSparse(
265293
const deps = dependencies[depType] || [];
266294
for (const depName of deps) {
267295
allPaths.push(`${sharedDir}/${depType}/${depName}`);
296+
allPaths.push(`${sharedDir}/${depType}/${depName}.*`);
268297
}
269298
}
270299

@@ -427,7 +456,12 @@ function buildSparseResult(
427456
for (const depType of depTypes) {
428457
const deps = dependencies[depType] || [];
429458
for (const depName of deps) {
430-
const sourceComp = path.join(tempDir, sharedDir, depType, depName);
459+
const sourceComp = findComponentPath(
460+
path.join(tempDir, sharedDir), depType, depName
461+
);
462+
if (!sourceComp) {
463+
continue;
464+
}
431465
// Get list of files in the component
432466
let files: string[] = [];
433467
try {
@@ -438,7 +472,7 @@ function buildSparseResult(
438472
return fstat.isFile();
439473
});
440474
} else if (stat.isFile()) {
441-
files = [depName];
475+
files = [path.basename(sourceComp)];
442476
}
443477
} catch {
444478
// Component doesn't exist
@@ -447,7 +481,7 @@ function buildSparseResult(
447481
result.shared[depType].push({
448482
name: depName,
449483
type: "shared",
450-
sourcePath: `${sharedDir}/${depType}/${depName}`,
484+
sourcePath: `${sharedDir}/${depType}/${path.basename(sourceComp)}`,
451485
files,
452486
});
453487
}
@@ -610,26 +644,26 @@ async function downloadSparseProject(
610644
for (const depType of depTypes) {
611645
const deps = dependencies[depType] || [];
612646
for (const depName of deps) {
613-
const sourceComp = path.join(tempDir, sharedDir, depType, depName);
614-
const targetComp = path.join(target, ".claude", depType, depName);
647+
const sourceComp = findComponentPath(
648+
path.join(tempDir, sharedDir), depType, depName
649+
);
650+
if (!sourceComp) {
651+
continue;
652+
}
653+
const targetComp = path.join(target, ".claude", depType, path.basename(sourceComp));
615654

616655
try {
617-
await fs.access(sourceComp);
618-
try {
619-
await fs.access(targetComp);
620-
// Already exists, skip
621-
} catch {
622-
// Doesn't exist, copy
623-
await fs.mkdir(path.dirname(targetComp), { recursive: true });
624-
const stat = await fs.stat(sourceComp);
625-
if (stat.isDirectory()) {
626-
await copyDir(sourceComp, targetComp, false);
627-
} else {
628-
await fs.copyFile(sourceComp, targetComp);
629-
}
630-
}
656+
await fs.access(targetComp);
657+
// Already exists, skip
631658
} catch {
632-
// Source doesn't exist, skip
659+
// Doesn't exist, copy
660+
await fs.mkdir(path.dirname(targetComp), { recursive: true });
661+
const stat = await fs.stat(sourceComp);
662+
if (stat.isDirectory()) {
663+
await copyDir(sourceComp, targetComp, false);
664+
} else {
665+
await fs.copyFile(sourceComp, targetComp);
666+
}
633667
}
634668
}
635669
}

typescript/src/utils/git.ts

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -168,14 +168,28 @@ export async function cleanupTempDir(tempDir: string): Promise<void> {
168168
/**
169169
* Copy a directory recursively.
170170
*/
171-
async function copyDir(src: string, dest: string): Promise<void> {
171+
async function copyDir(src: string, dest: string, exclude?: string): Promise<void> {
172172
await fs.mkdir(dest, { recursive: true });
173173
const entries = await fs.readdir(src, { withFileTypes: true });
174174

175175
for (const entry of entries) {
176+
if (exclude && entry.name === exclude) {
177+
continue;
178+
}
179+
176180
const srcPath = path.join(src, entry.name);
177181
const destPath = path.join(dest, entry.name);
178182

183+
// Skip broken symlinks
184+
const lstats = await fs.lstat(srcPath);
185+
if (lstats.isSymbolicLink()) {
186+
try {
187+
await fs.stat(srcPath); // follows symlink, throws if target missing
188+
} catch {
189+
continue; // broken symlink, skip
190+
}
191+
}
192+
179193
if (entry.isDirectory()) {
180194
await copyDir(srcPath, destPath);
181195
} else {
@@ -229,25 +243,9 @@ export async function sparseClonePaths(
229243
const sparseArgs = ["sparse-checkout", "set", "--no-cone", ...paths];
230244
await execCommand("git", sparseArgs, { cwd: tempClone });
231245

232-
// Step 3: Copy files to target (excluding .git)
246+
// Step 3: Copy entire sparse checkout to target (excluding .git)
233247
await fs.mkdir(targetDir, { recursive: true });
234-
for (const p of paths) {
235-
const src = path.join(tempClone, p);
236-
try {
237-
await fs.access(src);
238-
const dst = path.join(targetDir, p);
239-
await fs.mkdir(path.dirname(dst), { recursive: true });
240-
241-
const stat = await fs.stat(src);
242-
if (stat.isDirectory()) {
243-
await copyDir(src, dst);
244-
} else {
245-
await fs.copyFile(src, dst);
246-
}
247-
} catch {
248-
// Path doesn't exist in repo, skip
249-
}
250-
}
248+
await copyDir(tempClone, targetDir, ".git");
251249
} finally {
252250
await cleanupTempDir(tempClone);
253251
}

0 commit comments

Comments
 (0)