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/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)