project-init 0.3.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.
Files changed (173) hide show
  1. project_init/__init__.py +4 -0
  2. project_init/__main__.py +662 -0
  3. project_init/mcps.py +57 -0
  4. project_init/scaffold.py +374 -0
  5. project_init/templates/base/AGENTS.md.tmpl +50 -0
  6. project_init/templates/base/CLAUDE.md.tmpl +16 -0
  7. project_init/templates/base/CONTRIBUTING.md.tmpl +55 -0
  8. project_init/templates/base/GEMINI.md.tmpl +16 -0
  9. project_init/templates/base/LICENSE.tmpl +231 -0
  10. project_init/templates/base/SECURITY.md.tmpl +26 -0
  11. project_init/templates/base/docs/explanation/index.md +9 -0
  12. project_init/templates/base/docs/how-to/index.md +7 -0
  13. project_init/templates/base/docs/index.md.tmpl +20 -0
  14. project_init/templates/base/docs/reference/index.md +13 -0
  15. project_init/templates/base/docs/tutorials/index.md +7 -0
  16. project_init/templates/base/dot_claude/agents/README.md +30 -0
  17. project_init/templates/base/dot_claude/config.yaml.tmpl +31 -0
  18. project_init/templates/base/dot_claude/docs/README.md +26 -0
  19. project_init/templates/base/dot_claude/docs/adr/adr-001-memory-stack.md.tmpl +22 -0
  20. project_init/templates/base/dot_claude/docs/adr/adr-002-mcp-choices.md.tmpl +32 -0
  21. project_init/templates/base/dot_claude/docs/adr/adr-template.md +29 -0
  22. project_init/templates/base/dot_claude/docs/development/conventions.md.tmpl +31 -0
  23. project_init/templates/base/dot_claude/docs/development/testing.md +25 -0
  24. project_init/templates/base/dot_claude/docs/guides/developer-onboarding.md +110 -0
  25. project_init/templates/base/dot_claude/docs/guides/issue-metadata.md +27 -0
  26. project_init/templates/base/dot_claude/docs/guides/secrets.md +50 -0
  27. project_init/templates/base/dot_claude/docs/guides/using-memory.md +36 -0
  28. project_init/templates/base/dot_claude/hooks/README.md +15 -0
  29. project_init/templates/base/dot_claude/hooks/agent_guard_adapter.py.tmpl +64 -0
  30. project_init/templates/base/dot_claude/hooks/dag_workflow.py +610 -0
  31. project_init/templates/base/dot_claude/memory/MEMORY.md.tmpl +11 -0
  32. project_init/templates/base/dot_claude/memory/README.md +51 -0
  33. project_init/templates/base/dot_claude/memory/SCHEMA.md +52 -0
  34. project_init/templates/base/dot_claude/memory/feedback_conventions.md +11 -0
  35. project_init/templates/base/dot_claude/memory/project_context.md.tmpl +11 -0
  36. project_init/templates/base/dot_claude/memory/user_role.md +7 -0
  37. project_init/templates/base/dot_claude/project-init.md.tmpl +174 -0
  38. project_init/templates/base/dot_claude/rules/go.md +14 -0
  39. project_init/templates/base/dot_claude/rules/hooks.md +30 -0
  40. project_init/templates/base/dot_claude/rules/node.md +17 -0
  41. project_init/templates/base/dot_claude/rules/python.md +25 -0
  42. project_init/templates/base/dot_claude/scripts/README.md +15 -0
  43. project_init/templates/base/dot_claude/scripts/create_issue.sh +577 -0
  44. project_init/templates/base/dot_claude/scripts/create_nojira_pr.sh +3 -0
  45. project_init/templates/base/dot_claude/scripts/finish_pr.sh +3 -0
  46. project_init/templates/base/dot_claude/scripts/install_hooks.sh +55 -0
  47. project_init/templates/base/dot_claude/scripts/monitor_pr.sh +270 -0
  48. project_init/templates/base/dot_claude/scripts/promote_review.sh +3 -0
  49. project_init/templates/base/dot_claude/scripts/push_branch.sh +5 -0
  50. project_init/templates/base/dot_claude/scripts/push_wiki.sh +34 -0
  51. project_init/templates/base/dot_claude/scripts/setup_github.sh +219 -0
  52. project_init/templates/base/dot_claude/scripts/start_issue.sh +134 -0
  53. project_init/templates/base/dot_claude/settings.json.tmpl +83 -0
  54. project_init/templates/base/dot_claude/skills/README.md +12 -0
  55. project_init/templates/base/dot_claude/skills/plan/SKILL.md.tmpl +40 -0
  56. project_init/templates/base/dot_claude/vault/README.md +21 -0
  57. project_init/templates/base/dot_claude/vault/decisions/README.md +22 -0
  58. project_init/templates/base/dot_claude/vault/design/README.md +3 -0
  59. project_init/templates/base/dot_claude/vault/knowledge/README.md +5 -0
  60. project_init/templates/base/dot_claude/vault/sessions/README.md +5 -0
  61. project_init/templates/base/dot_devcontainer/devcontainer.json.tmpl +17 -0
  62. project_init/templates/base/dot_devcontainer/post-create.sh.tmpl +31 -0
  63. project_init/templates/base/dot_env.example.tmpl +13 -0
  64. project_init/templates/base/dot_github/CODEOWNERS.tmpl +12 -0
  65. project_init/templates/base/dot_github/ISSUE_TEMPLATE/bug.yml +98 -0
  66. project_init/templates/base/dot_github/ISSUE_TEMPLATE/chore.yml +82 -0
  67. project_init/templates/base/dot_github/ISSUE_TEMPLATE/config.yml +5 -0
  68. project_init/templates/base/dot_github/ISSUE_TEMPLATE/docs.yml +84 -0
  69. project_init/templates/base/dot_github/ISSUE_TEMPLATE/feature.yml +87 -0
  70. project_init/templates/base/dot_github/ISSUE_TEMPLATE/test.yml +90 -0
  71. project_init/templates/base/dot_github/copilot-instructions.md.tmpl +25 -0
  72. project_init/templates/base/dot_github/hooks/commit-msg +52 -0
  73. project_init/templates/base/dot_github/hooks/pre-commit +16 -0
  74. project_init/templates/base/dot_github/hooks/pre-push +51 -0
  75. project_init/templates/base/dot_github/pull_request_template.md +22 -0
  76. project_init/templates/base/dot_github/workflows/board-automation.yml +232 -0
  77. project_init/templates/base/dot_github/workflows/ci.yml.tmpl +204 -0
  78. project_init/templates/base/dot_github/workflows/docs.yml.tmpl +98 -0
  79. project_init/templates/base/dot_github/workflows/issue-validation.yml +72 -0
  80. project_init/templates/base/dot_github/workflows/review-status.yml +48 -0
  81. project_init/templates/base/dot_github/workflows/validate-pr.yml +103 -0
  82. project_init/templates/base/dot_gitignore.tmpl +41 -0
  83. project_init/templates/base/dot_golangci.yml.tmpl +20 -0
  84. project_init/templates/base/dot_vscode/extensions.json.tmpl +10 -0
  85. project_init/templates/base/dot_vscode/settings.json.tmpl +8 -0
  86. project_init/templates/base/eslint.config.mjs.tmpl +29 -0
  87. project_init/templates/base/justfile.tmpl +95 -0
  88. project_init/templates/base/mise.toml.tmpl +20 -0
  89. project_init/templates/base/mkdocs.yml.tmpl +32 -0
  90. project_init/templates/base/renovate.json +14 -0
  91. project_init/templates/base/ruff.toml.tmpl +31 -0
  92. project_init/templates/base/typedoc.json.tmpl +14 -0
  93. project_init/templates/codex/dot_agents/skills/add_adr/SKILL.md +33 -0
  94. project_init/templates/codex/dot_agents/skills/add_command/SKILL.md +63 -0
  95. project_init/templates/codex/dot_agents/skills/add_hook/SKILL.md +112 -0
  96. project_init/templates/codex/dot_agents/skills/audit/SKILL.md +146 -0
  97. project_init/templates/codex/dot_agents/skills/create_issue/SKILL.md +59 -0
  98. project_init/templates/codex/dot_agents/skills/github_workflow/SKILL.md +80 -0
  99. project_init/templates/codex/dot_agents/skills/request_review/SKILL.md +19 -0
  100. project_init/templates/codex/dot_agents/skills/review/SKILL.md +17 -0
  101. project_init/templates/codex/dot_agents/skills/save_memory/SKILL.md +17 -0
  102. project_init/templates/codex/dot_agents/skills/session_summary/SKILL.md +35 -0
  103. project_init/templates/codex/dot_agents/skills/start_task/SKILL.md +48 -0
  104. project_init/templates/codex/dot_agents/skills/status/SKILL.md +15 -0
  105. project_init/templates/codex/dot_codex/hooks.json.tmpl +17 -0
  106. project_init/templates/fallback/dot_claude/hooks/github_command_guard.sh +11 -0
  107. project_init/templates/fallback/dot_claude/hooks/post_edit_lint.sh +58 -0
  108. project_init/templates/fallback/dot_claude/hooks/pre_commit_gate.sh +81 -0
  109. project_init/templates/fallback/dot_claude/hooks/prod_guard.py +140 -0
  110. project_init/templates/fallback/dot_claude/hooks/session_setup.sh +62 -0
  111. project_init/templates/fallback/dot_claude/hooks/workflow_state_reminder.sh +72 -0
  112. project_init/templates/fallback/dot_claude/skills/INDEX.md +28 -0
  113. project_init/templates/fallback/dot_claude/skills/add_adr/SKILL.md +33 -0
  114. project_init/templates/fallback/dot_claude/skills/add_command/SKILL.md +63 -0
  115. project_init/templates/fallback/dot_claude/skills/add_hook/SKILL.md +112 -0
  116. project_init/templates/fallback/dot_claude/skills/audit/SKILL.md +146 -0
  117. project_init/templates/fallback/dot_claude/skills/create_issue/SKILL.md +59 -0
  118. project_init/templates/fallback/dot_claude/skills/github_workflow/SKILL.md +80 -0
  119. project_init/templates/fallback/dot_claude/skills/request_review/SKILL.md +19 -0
  120. project_init/templates/fallback/dot_claude/skills/review/SKILL.md +17 -0
  121. project_init/templates/fallback/dot_claude/skills/save_memory/SKILL.md +17 -0
  122. project_init/templates/fallback/dot_claude/skills/session_summary/SKILL.md +35 -0
  123. project_init/templates/fallback/dot_claude/skills/start_task/SKILL.md +48 -0
  124. project_init/templates/fallback/dot_claude/skills/status/SKILL.md +15 -0
  125. project_init/templates/gemini/dot_agents/skills/add_adr/SKILL.md +33 -0
  126. project_init/templates/gemini/dot_agents/skills/add_command/SKILL.md +63 -0
  127. project_init/templates/gemini/dot_agents/skills/add_hook/SKILL.md +112 -0
  128. project_init/templates/gemini/dot_agents/skills/audit/SKILL.md +146 -0
  129. project_init/templates/gemini/dot_agents/skills/create_issue/SKILL.md +59 -0
  130. project_init/templates/gemini/dot_agents/skills/github_workflow/SKILL.md +80 -0
  131. project_init/templates/gemini/dot_agents/skills/request_review/SKILL.md +19 -0
  132. project_init/templates/gemini/dot_agents/skills/review/SKILL.md +17 -0
  133. project_init/templates/gemini/dot_agents/skills/save_memory/SKILL.md +17 -0
  134. project_init/templates/gemini/dot_agents/skills/session_summary/SKILL.md +35 -0
  135. project_init/templates/gemini/dot_agents/skills/start_task/SKILL.md +48 -0
  136. project_init/templates/gemini/dot_agents/skills/status/SKILL.md +15 -0
  137. project_init/templates/gemini/dot_claude/scripts/setup_gemini.sh.tmpl +16 -0
  138. project_init/templates/gemini/dot_gemini-extension/commands/add_adr.toml +5 -0
  139. project_init/templates/gemini/dot_gemini-extension/commands/add_command.toml +5 -0
  140. project_init/templates/gemini/dot_gemini-extension/commands/add_hook.toml +5 -0
  141. project_init/templates/gemini/dot_gemini-extension/commands/audit.toml +5 -0
  142. project_init/templates/gemini/dot_gemini-extension/commands/create_issue.toml +5 -0
  143. project_init/templates/gemini/dot_gemini-extension/commands/github_workflow.toml +5 -0
  144. project_init/templates/gemini/dot_gemini-extension/commands/request_review.toml +5 -0
  145. project_init/templates/gemini/dot_gemini-extension/commands/review.toml +5 -0
  146. project_init/templates/gemini/dot_gemini-extension/commands/save_memory.toml +5 -0
  147. project_init/templates/gemini/dot_gemini-extension/commands/session_summary.toml +5 -0
  148. project_init/templates/gemini/dot_gemini-extension/commands/start_task.toml +5 -0
  149. project_init/templates/gemini/dot_gemini-extension/commands/status.toml +5 -0
  150. project_init/templates/gemini/dot_gemini-extension/gemini-extension.json.tmpl +6 -0
  151. project_init/templates/gemini/dot_gemini-extension/hooks/hooks.json.tmpl +18 -0
  152. project_init/templates/graphify/dot_claude/docs/guides/using-graphify.md +37 -0
  153. project_init/templates/graphify/dot_claude/rules/graphify.md +18 -0
  154. project_init/templates/graphify/dot_claude/scripts/setup_graphify.sh +40 -0
  155. project_init/templates/obsidian/dot_claude/scripts/lint_memory.sh +115 -0
  156. project_init/templates/obsidian/dot_claude/vault/decisions/adr-000-project-setup.md.tmpl +22 -0
  157. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/README.md +31 -0
  158. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/app.json +6 -0
  159. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/community-plugins.json +1 -0
  160. project_init/templates/obsidian/dot_claude/vault/dot_obsidian/core-plugins.json +1 -0
  161. project_init/templates/obsidian/dot_claude/vault/log.md +6 -0
  162. project_init/templates/obsidian/dot_claude/vault/templates/decision.md +16 -0
  163. project_init/templates/obsidian/dot_claude/vault/templates/design-note.md +14 -0
  164. project_init/templates/obsidian/dot_claude/vault/templates/knowledge-note.md +12 -0
  165. project_init/templates/obsidian/dot_claude/vault/templates/session-note.md +16 -0
  166. project_init/templates/presets/obsidian-graphify.toml +16 -0
  167. project_init/templates/presets/obsidian-only.toml +14 -0
  168. project_init/upgrade.py +569 -0
  169. project_init-0.3.0.dist-info/METADATA +342 -0
  170. project_init-0.3.0.dist-info/RECORD +173 -0
  171. project_init-0.3.0.dist-info/WHEEL +4 -0
  172. project_init-0.3.0.dist-info/entry_points.txt +2 -0
  173. project_init-0.3.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,4 @@
1
+ """project-init — scaffolder for agentic-development infrastructure."""
2
+
3
+ __version__ = "0.3.0"
4
+ __repo_url__ = "https://github.com/VytCepas/project-init"
@@ -0,0 +1,662 @@
1
+ """CLI entry point for `project-init` and `uvx project-init`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ from project_init import __repo_url__, __version__
11
+ from project_init.mcps import (
12
+ DB_CATALOG,
13
+ MCP_CATALOG,
14
+ PLAYWRIGHT_MCP,
15
+ format_installed_mcps,
16
+ format_installed_mcps_yaml,
17
+ )
18
+ from project_init.scaffold import (
19
+ TemplateRenderError,
20
+ list_presets,
21
+ load_preset,
22
+ scaffold,
23
+ )
24
+
25
+
26
+ def _build_parser() -> argparse.ArgumentParser:
27
+ p = argparse.ArgumentParser(
28
+ prog="project-init",
29
+ description="Scaffold agentic-development infrastructure into a project.",
30
+ epilog=(
31
+ "Subcommand: project-init upgrade [target] [--apply] — re-render "
32
+ "from the recorded config and report drift (PI-142)."
33
+ ),
34
+ )
35
+ p.add_argument(
36
+ "target",
37
+ nargs="?",
38
+ default=".",
39
+ help="Target directory (default: current directory)",
40
+ )
41
+ p.add_argument("--preset", help="Preset name (skip interactive selection)")
42
+ p.add_argument("--name", help="Project name")
43
+ p.add_argument("--description", help="One-line project description")
44
+ p.add_argument(
45
+ "--language",
46
+ choices=["python", "node", "go", "none"],
47
+ help="Primary language/runtime",
48
+ )
49
+ p.add_argument(
50
+ "--mcps",
51
+ default="",
52
+ help="Comma-separated MCP IDs from the core catalog (e.g. context7)",
53
+ )
54
+ p.add_argument(
55
+ "--db",
56
+ choices=["none", "postgres", "sqlite"],
57
+ default="none",
58
+ help="Database MCP to add (default: none)",
59
+ )
60
+ p.add_argument(
61
+ "--browser",
62
+ action="store_true",
63
+ help="Add Playwright browser-automation MCP",
64
+ )
65
+ p.add_argument(
66
+ "--license",
67
+ choices=["mit", "apache-2.0", "proprietary", "none"],
68
+ default="none",
69
+ help="LICENSE file to render (default: none — no file)",
70
+ )
71
+ p.add_argument(
72
+ "--owner",
73
+ default="",
74
+ help=(
75
+ "Project owner/team: CODEOWNERS default owner (@user or "
76
+ "@org/team), SECURITY contact, and LICENSE copyright holder"
77
+ ),
78
+ )
79
+ p.add_argument(
80
+ "--agents",
81
+ default="claude",
82
+ help=(
83
+ "Comma-separated agents the project supports: claude (always "
84
+ "included), codex, gemini, ollama. Codex/Gemini get native "
85
+ "wiring overlays; ollama is instructions-level only (PI-137)"
86
+ ),
87
+ )
88
+ p.add_argument(
89
+ "--mise",
90
+ action="store_true",
91
+ help=(
92
+ "Render mise.toml pinning toolchain versions (mise owns versions "
93
+ "only; uv/bun own deps, just owns commands, .env owns environment)"
94
+ ),
95
+ )
96
+ p.add_argument(
97
+ "--vscode",
98
+ action="store_true",
99
+ help=(
100
+ "Render .vscode/extensions.json + minimal settings.json "
101
+ "(format-on-save wired to the preset formatter; nothing personal)"
102
+ ),
103
+ )
104
+ p.add_argument(
105
+ "--devcontainer",
106
+ action="store_true",
107
+ help=(
108
+ "Render .devcontainer/ (base image + toolchain bootstrap) for "
109
+ "Codespaces, fresh clones, and remote agent sessions"
110
+ ),
111
+ )
112
+ p.add_argument(
113
+ "--no-plugin",
114
+ action="store_true",
115
+ help=(
116
+ "Copy hooks/skills into the project and wire them in settings "
117
+ "instead of relying on the project-init-workflow plugin "
118
+ "(offline / no-marketplace-trust fallback; ADR-010 cutover)"
119
+ ),
120
+ )
121
+ p.add_argument(
122
+ "--non-interactive",
123
+ action="store_true",
124
+ help="Skip all prompts (requires --preset, --name, --description)",
125
+ )
126
+ p.add_argument(
127
+ "--strict",
128
+ action="store_true",
129
+ help="Fail if any {{...}} placeholder survives rendering (PI-17)",
130
+ )
131
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
132
+ return p
133
+
134
+
135
+ def _prompt(label: str, default: str = "") -> str:
136
+ from rich.prompt import Prompt
137
+
138
+ return Prompt.ask(label, default=default) or default
139
+
140
+
141
+ def _choose_preset_interactive(presets: list[dict]) -> dict:
142
+ from rich.console import Console
143
+ from rich.prompt import IntPrompt
144
+
145
+ console = Console()
146
+ console.print("\n[bold]Available presets:[/bold]")
147
+ for i, p in enumerate(presets, 1):
148
+ console.print(f" [cyan]{i}[/cyan]. {p['name']} — {p['description']}")
149
+ console.print()
150
+
151
+ choice = IntPrompt.ask("Choose a preset", default=1)
152
+ if choice < 1 or choice > len(presets):
153
+ console.print("[red]Invalid choice. Using preset 1.[/red]")
154
+ choice = 1
155
+ return presets[choice - 1]
156
+
157
+
158
+ def _choose_mcps_interactive(catalog: list[dict]) -> list[dict]:
159
+ from rich.console import Console
160
+ from rich.prompt import Prompt
161
+
162
+ console = Console()
163
+ console.print("\n[bold]MCPs to install:[/bold]")
164
+ for i, m in enumerate(catalog, 1):
165
+ console.print(f" [cyan]{i}[/cyan]. {m['name']} — {m['description']}")
166
+ console.print()
167
+
168
+ raw = Prompt.ask(
169
+ "Choose MCPs (comma-separated numbers, or Enter to skip)",
170
+ default="",
171
+ )
172
+ if not raw.strip():
173
+ return []
174
+
175
+ selected = []
176
+ seen: set[str] = set()
177
+ for part in raw.split(","):
178
+ part = part.strip()
179
+ if part.isdigit():
180
+ idx = int(part) - 1
181
+ if 0 <= idx < len(catalog) and catalog[idx]["id"] not in seen:
182
+ selected.append(catalog[idx])
183
+ seen.add(catalog[idx]["id"])
184
+ return selected
185
+
186
+
187
+ def _choose_db_interactive() -> dict | None:
188
+ from rich.console import Console
189
+ from rich.prompt import IntPrompt
190
+
191
+ console = Console()
192
+ console.print("\n[bold]Database MCP:[/bold]")
193
+ console.print(" [cyan]1[/cyan]. None")
194
+ console.print(" [cyan]2[/cyan]. Postgres")
195
+ console.print(" [cyan]3[/cyan]. SQLite")
196
+ console.print()
197
+
198
+ choice = IntPrompt.ask("Choose", default=1)
199
+ if choice == 2:
200
+ return DB_CATALOG["postgres"]
201
+ if choice == 3:
202
+ return DB_CATALOG["sqlite"]
203
+ return None
204
+
205
+
206
+ def _choose_browser_interactive() -> bool:
207
+ from rich.prompt import Confirm
208
+
209
+ return Confirm.ask("\nAdd Playwright (browser automation)?", default=False)
210
+
211
+
212
+ def _resolve_mcps_non_interactive(
213
+ mcps_arg: str,
214
+ db_arg: str,
215
+ browser_arg: bool,
216
+ ) -> list[dict]:
217
+ """Parse non-interactive MCP flags into a flat list of selected MCPs.
218
+
219
+ Raises ValueError on unknown MCP IDs — silently ignoring them hides typos.
220
+ """
221
+ catalog_by_id = {m["id"]: m for m in MCP_CATALOG}
222
+ selected: list[dict] = []
223
+ seen: set[str] = set()
224
+ unknown: list[str] = []
225
+
226
+ for raw_id in mcps_arg.split(","):
227
+ mcp_id = raw_id.strip().lower()
228
+ if not mcp_id:
229
+ continue
230
+ if mcp_id not in catalog_by_id:
231
+ unknown.append(mcp_id)
232
+ continue
233
+ if mcp_id in seen:
234
+ continue
235
+ selected.append(catalog_by_id[mcp_id])
236
+ seen.add(mcp_id)
237
+
238
+ if unknown:
239
+ valid = ", ".join(catalog_by_id.keys())
240
+ msg = f"unknown MCP id(s): {', '.join(unknown)}. Valid: {valid}"
241
+ raise ValueError(msg)
242
+
243
+ if db_arg and db_arg != "none" and db_arg in DB_CATALOG:
244
+ selected.append(DB_CATALOG[db_arg])
245
+
246
+ if browser_arg:
247
+ selected.append(PLAYWRIGHT_MCP)
248
+
249
+ return selected
250
+
251
+
252
+ def _print_summary(target: Path, created: list[Path], preset_name: str) -> None:
253
+ from rich.console import Console
254
+ from rich.panel import Panel
255
+
256
+ console = Console()
257
+
258
+ dirs = sorted({str(p.parent) for p in created if str(p.parent) != "."})
259
+ files_count = len(created)
260
+
261
+ body = f"[bold]Preset:[/bold] {preset_name}\n"
262
+ body += f"[bold]Files:[/bold] {files_count} created/updated\n"
263
+ body += f"[bold]Target:[/bold] {target.resolve()}\n\n"
264
+ body += "[bold]Directories:[/bold]\n"
265
+ for d in dirs[:15]:
266
+ body += f" {d}/\n"
267
+ if len(dirs) > 15:
268
+ body += f" ... and {len(dirs) - 15} more\n"
269
+
270
+ console.print()
271
+ console.print(Panel(body.rstrip(), title="project-init", border_style="green"))
272
+ console.print()
273
+
274
+
275
+ def _print_conflicts(conflicts: list[tuple[Path, Path]]) -> None:
276
+ """Warn that user-owned files were kept; renders landed as .new siblings."""
277
+ from rich.console import Console
278
+ from rich.panel import Panel
279
+
280
+ console = Console()
281
+ body = (
282
+ "Your existing files were [bold]not overwritten[/bold]. The new "
283
+ "project-init version of each was written alongside as a sibling — "
284
+ "review and merge what you want, then delete the sibling:\n\n"
285
+ )
286
+ body += "\n".join(f" {original} → {sibling}" for original, sibling in sorted(conflicts))
287
+ console.print(Panel(body, title="Existing files preserved", border_style="yellow"))
288
+ console.print()
289
+
290
+
291
+ def _print_mcp_commands(selected: list[dict]) -> None:
292
+ """Print the bare claude mcp add commands for the chosen MCPs."""
293
+ if not selected:
294
+ return
295
+
296
+ from rich.console import Console
297
+ from rich.panel import Panel
298
+
299
+ console = Console()
300
+ body = "\n".join(m["command"] for m in selected)
301
+ console.print(
302
+ Panel(
303
+ body,
304
+ title="Next step — add MCPs (run in your project)",
305
+ border_style="cyan",
306
+ )
307
+ )
308
+ console.print()
309
+
310
+
311
+ def _require_non_interactive_args(
312
+ args: argparse.Namespace, parser: argparse.ArgumentParser
313
+ ) -> None:
314
+ """Fail fast when --non-interactive is missing one of its required flags."""
315
+ missing = []
316
+ if not args.preset:
317
+ missing.append("--preset")
318
+ if not args.name:
319
+ missing.append("--name")
320
+ if not args.description:
321
+ missing.append("--description")
322
+ if missing:
323
+ parser.error(f"--non-interactive requires: {', '.join(missing)}")
324
+
325
+
326
+ def _select_preset(
327
+ args: argparse.Namespace, parser: argparse.ArgumentParser, presets: list[dict]
328
+ ) -> dict:
329
+ """Resolve the preset from flags or interactive choice (exits on bad --preset)."""
330
+ if args.preset:
331
+ try:
332
+ return load_preset(args.preset)
333
+ except ValueError as e:
334
+ parser.error(str(e))
335
+ if args.non_interactive:
336
+ return presets[0]
337
+ return _choose_preset_interactive(presets)
338
+
339
+
340
+ def _gather_inputs_interactive(
341
+ default_name: str,
342
+ ) -> tuple[str, str, str, list[dict], str, str, bool, bool, bool, list[str]]:
343
+ """Prompt for project basics, MCPs, governance, and opt-in overlays."""
344
+ project_name = _prompt("Project name", default=default_name)
345
+ project_description = _prompt("Description", default="")
346
+ language = _prompt("Language (python/node/go/none)", default="none")
347
+ if language not in {"python", "node", "go", "none"}:
348
+ language = "none"
349
+
350
+ # MCP selection — three steps.
351
+ selected_mcps = _choose_mcps_interactive(MCP_CATALOG)
352
+ db_mcp = _choose_db_interactive()
353
+ if db_mcp:
354
+ selected_mcps = selected_mcps + [db_mcp]
355
+ if _choose_browser_interactive():
356
+ selected_mcps = selected_mcps + [PLAYWRIGHT_MCP]
357
+
358
+ # Governance (PI-145).
359
+ owner = _prompt("Owner/team for CODEOWNERS + LICENSE (e.g. @org/team)", default="")
360
+ license_choice = _prompt("License (mit/apache-2.0/proprietary/none)", default="none")
361
+ if license_choice not in {"mit", "apache-2.0", "proprietary", "none"}:
362
+ license_choice = "none"
363
+
364
+ from rich.prompt import Confirm
365
+
366
+ devcontainer = Confirm.ask(
367
+ "Add a devcontainer (Codespaces / remote agent sessions)?", default=False
368
+ )
369
+ mise = Confirm.ask("Pin toolchain versions with mise (mise.toml)?", default=False)
370
+ vscode = Confirm.ask("Add shared VS Code config (extensions + format-on-save)?", default=False)
371
+ while True:
372
+ agents_raw = _prompt(
373
+ "Agents to support (claude always; add codex/gemini/ollama, comma-separated)",
374
+ default="claude",
375
+ )
376
+ try:
377
+ agents = resolve_agents(agents_raw)
378
+ break
379
+ except ValueError as e:
380
+ from rich.console import Console
381
+
382
+ Console().print(f"[red]{e}[/red]")
383
+ return (
384
+ project_name,
385
+ project_description,
386
+ language,
387
+ selected_mcps,
388
+ owner,
389
+ license_choice,
390
+ devcontainer,
391
+ mise,
392
+ vscode,
393
+ agents,
394
+ )
395
+
396
+
397
+ _VALID_AGENTS = ("claude", "codex", "gemini", "ollama")
398
+ # Agents whose native wiring ships as a template layer; ollama is
399
+ # instructions-level only (canonical AGENTS.md + portable scripts, PI-137).
400
+ _AGENT_LAYERS = ("codex", "gemini")
401
+
402
+
403
+ def resolve_agents(raw: str) -> list[str]:
404
+ """Parse/validate an --agents value; claude is always included first."""
405
+ selected = [a.strip().lower() for a in raw.split(",") if a.strip()]
406
+ unknown = [a for a in selected if a not in _VALID_AGENTS]
407
+ if unknown:
408
+ msg = f"unknown agent(s): {', '.join(unknown)}. Valid: {', '.join(_VALID_AGENTS)}"
409
+ raise ValueError(msg)
410
+ ordered = ["claude"]
411
+ ordered += [a for a in _VALID_AGENTS if a != "claude" and a in selected]
412
+ return ordered
413
+
414
+
415
+ def agent_layers(agents: list[str]) -> list[str]:
416
+ """Template layers contributed by the selected agents."""
417
+ return [a for a in _AGENT_LAYERS if a in agents]
418
+
419
+
420
+ # Per-language tooling commands (PI-16): (lint, format, test). Empty strings
421
+ # when no convention applies — templates should wrap usages in
422
+ # {{#if python}}/{{#if node}}/etc.
423
+ _LANGUAGE_COMMANDS: dict[str, tuple[str, str, str]] = {
424
+ "python": ("uv run ruff check .", "uv run ruff format .", "uv run pytest"),
425
+ # node recipes call the tools directly (PI-180): a freshly scaffolded
426
+ # project has no package.json scripts to back `bun run lint`/`format`.
427
+ "node": ("bunx eslint .", "bunx @biomejs/biome format --write .", "bun test"),
428
+ "go": ("golangci-lint run", "gofmt -w .", "go test ./..."),
429
+ }
430
+
431
+
432
+ def _upgrade_main(argv: list[str]) -> int:
433
+ """Parse and run the `project-init upgrade` subcommand (PI-142)."""
434
+ from project_init.upgrade import run_upgrade
435
+
436
+ p = argparse.ArgumentParser(
437
+ prog="project-init upgrade",
438
+ description=(
439
+ "Re-render the recorded preset at the current template version "
440
+ "and report drift. Without --apply no files are touched."
441
+ ),
442
+ )
443
+ p.add_argument(
444
+ "target",
445
+ nargs="?",
446
+ default=".",
447
+ help="Scaffolded project directory (default: current directory)",
448
+ )
449
+ p.add_argument(
450
+ "--apply",
451
+ action="store_true",
452
+ help="Apply non-conflicting changes; conflicts become .new siblings",
453
+ )
454
+ p.add_argument(
455
+ "--no-plugin",
456
+ action="store_true",
457
+ help=(
458
+ "Switch the project to the no-plugin fallback on this upgrade: "
459
+ "re-render with copied hooks/skills + local settings wiring"
460
+ ),
461
+ )
462
+ p.add_argument(
463
+ "--non-interactive",
464
+ action="store_true",
465
+ help="Accepted for CLI symmetry — upgrade never prompts",
466
+ )
467
+ args = p.parse_args(argv)
468
+ return run_upgrade(Path(args.target).resolve(), apply=args.apply, no_plugin=args.no_plugin)
469
+
470
+
471
+ def _build_variables( # noqa: PLR0913 — one variable per wizard input
472
+ preset: dict,
473
+ *,
474
+ project_name: str,
475
+ project_description: str,
476
+ language: str,
477
+ selected_mcps: list[dict],
478
+ owner: str,
479
+ license_choice: str,
480
+ devcontainer: bool,
481
+ mise: bool,
482
+ vscode: bool,
483
+ agents: list[str],
484
+ no_plugin: bool,
485
+ ) -> dict[str, str]:
486
+ """Assemble the template render context from the resolved inputs."""
487
+ is_graphify = "graphify" in preset.get("name", "")
488
+ has_obsidian = "obsidian" in preset.get("layers", [])
489
+ lint_command, format_command, test_command = _LANGUAGE_COMMANDS.get(language, ("", "", ""))
490
+ return {
491
+ "project_name": project_name,
492
+ "project_description": project_description,
493
+ "created_date": date.today().isoformat(),
494
+ "project_init_version": __version__,
495
+ "project_init_url": __repo_url__,
496
+ # owner/name slug for the same-repo plugin marketplace (ADR-010)
497
+ "project_init_repo": __repo_url__.removeprefix("https://github.com/"),
498
+ "language": language,
499
+ "memory_stack": preset.get("vars", {}).get("memory_stack", "obsidian-only"),
500
+ "installed_mcps": format_installed_mcps(selected_mcps),
501
+ "installed_mcps_yaml": format_installed_mcps_yaml(selected_mcps),
502
+ "lint_command": lint_command,
503
+ "format_command": format_command,
504
+ "test_command": test_command,
505
+ # Governance (PI-145). license_holder falls back to the project name
506
+ # so a LICENSE rendered without --owner still has a copyright line.
507
+ # The leading "@" is required for CODEOWNERS (project_owner) but is a
508
+ # GitHub-handle artifact in a legal copyright notice, so strip it for
509
+ # the license holder only (PI-181).
510
+ "project_owner": owner,
511
+ "license": license_choice,
512
+ "license_holder": (owner or project_name).removeprefix("@"),
513
+ "created_year": date.today().strftime("%Y"),
514
+ # Conditional block flags (truthy/falsy strings).
515
+ "python": "true" if language == "python" else "",
516
+ "node": "true" if language == "node" else "",
517
+ "go": "true" if language == "go" else "",
518
+ "justfile": "true" if language != "none" else "",
519
+ "devcontainer": "true" if devcontainer else "",
520
+ # Multi-agent support (PI-137): the agents list drives overlay layers
521
+ # on upgrade re-render; per-agent flags gate conditional blocks.
522
+ "agents": ",".join(agents),
523
+ "codex": "true" if "codex" in agents else "",
524
+ "gemini": "true" if "gemini" in agents else "",
525
+ "ollama": "true" if "ollama" in agents else "",
526
+ "multi_agent": "true" if ("codex" in agents or "gemini" in agents) else "",
527
+ "other_agents": "true" if len(agents) > 1 else "",
528
+ # Plugin cutover (PI-165): inverse pair, same pattern as vscode_off.
529
+ "plugin_mode": "" if no_plugin else "true",
530
+ "no_plugin": "true" if no_plugin else "",
531
+ "mise": "true" if mise else "",
532
+ "vscode": "true" if vscode else "",
533
+ # Inverse flag: the template engine has no else-branch, and without
534
+ # --vscode the gitignore must keep personal .vscode/ fully ignored.
535
+ "vscode_off": "" if vscode else "true",
536
+ "graphify": "true" if is_graphify else "",
537
+ "obsidian": "true" if has_obsidian else "",
538
+ "license_mit": "true" if license_choice == "mit" else "",
539
+ "license_apache": "true" if license_choice == "apache-2.0" else "",
540
+ "license_proprietary": "true" if license_choice == "proprietary" else "",
541
+ }
542
+
543
+
544
+ def _resolve_inputs(args, parser, target: Path) -> tuple | None:
545
+ """Resolve all scaffold inputs from flags; None means prompt instead.
546
+
547
+ Validation errors call ``parser.error`` (exits) BEFORE the target dir is
548
+ created (PI-20), so a typo'd flag never leaves an empty dir behind.
549
+ """
550
+ if not args.non_interactive:
551
+ return None
552
+ try:
553
+ selected_mcps = _resolve_mcps_non_interactive(args.mcps, args.db, args.browser)
554
+ agents = resolve_agents(args.agents)
555
+ except ValueError as e:
556
+ parser.error(str(e))
557
+ return (
558
+ args.name,
559
+ args.description,
560
+ args.language or "none",
561
+ selected_mcps,
562
+ args.owner,
563
+ args.license,
564
+ args.devcontainer,
565
+ args.mise,
566
+ args.vscode,
567
+ agents,
568
+ args.no_plugin,
569
+ )
570
+
571
+
572
+ def main(argv: list[str] | None = None) -> int:
573
+ """Run the scaffolding CLI; return the process exit code."""
574
+ argv = list(sys.argv[1:]) if argv is None else list(argv)
575
+ if argv[:1] == ["upgrade"]:
576
+ return _upgrade_main(argv[1:])
577
+ parser = _build_parser()
578
+ args = parser.parse_args(argv)
579
+
580
+ if args.non_interactive:
581
+ _require_non_interactive_args(args, parser)
582
+
583
+ target = Path(args.target).resolve()
584
+
585
+ # Select preset BEFORE creating the target directory — a typo'd --preset
586
+ # should fail without leaving an empty dir behind.
587
+ presets = list_presets()
588
+ if not presets:
589
+ sys.stderr.write("error: no presets found in templates/presets/\n")
590
+ return 1
591
+ preset = _select_preset(args, parser, presets)
592
+
593
+ # Validate non-interactive args BEFORE creating the target directory
594
+ # (PI-20: a bad flag must not leave an empty dir behind).
595
+ inputs = _resolve_inputs(args, parser, target)
596
+ target.mkdir(parents=True, exist_ok=True)
597
+ if inputs is None:
598
+ inputs = _gather_inputs_interactive(default_name=target.name) + (args.no_plugin,)
599
+ (
600
+ project_name,
601
+ project_description,
602
+ language,
603
+ selected_mcps,
604
+ owner,
605
+ license_choice,
606
+ devcontainer,
607
+ mise,
608
+ vscode,
609
+ agents,
610
+ no_plugin,
611
+ ) = inputs
612
+
613
+ # Agent overlays append to the preset's layers (PI-137); --no-plugin
614
+ # restores the shared hooks/skills copies via the fallback layer
615
+ # (PI-165, ADR-010 cutover). The preset dict is copied so the loaded
616
+ # definition stays pristine.
617
+ extra_layers = agent_layers(agents)
618
+ if no_plugin:
619
+ extra_layers = ["fallback", *extra_layers]
620
+ if extra_layers:
621
+ preset = {**preset, "layers": list(preset["layers"]) + extra_layers}
622
+
623
+ variables = _build_variables(
624
+ preset,
625
+ project_name=project_name,
626
+ project_description=project_description,
627
+ language=language,
628
+ selected_mcps=selected_mcps,
629
+ owner=owner,
630
+ license_choice=license_choice,
631
+ devcontainer=devcontainer,
632
+ mise=mise,
633
+ vscode=vscode,
634
+ agents=agents,
635
+ no_plugin=no_plugin,
636
+ )
637
+
638
+ # Overwrite protection (PI-179): scaffold() decides per file whether it is
639
+ # user-owned (first scaffold, or an unresolved `.new` sibling still pending)
640
+ # and writes a `.new` sibling rather than clobbering it. Always pass the list
641
+ # so a re-run before the user merges a prior conflict stays protected too.
642
+ conflicts: list[tuple[Path, Path]] = []
643
+ try:
644
+ created = scaffold(target, preset, variables, strict=args.strict, conflicts=conflicts)
645
+ except TemplateRenderError as e:
646
+ sys.stderr.write(f"error: {e}\n")
647
+ return 2
648
+
649
+ # Record the scaffold inputs + rendered-content hashes so a later
650
+ # `project-init upgrade` can re-render faithfully and detect drift.
651
+ from project_init.upgrade import write_scaffold_record
652
+
653
+ write_scaffold_record(target, preset["name"], variables, created)
654
+ _print_summary(target, created, preset["name"])
655
+ if conflicts:
656
+ _print_conflicts(conflicts)
657
+ _print_mcp_commands(selected_mcps)
658
+ return 0
659
+
660
+
661
+ if __name__ == "__main__":
662
+ sys.exit(main())