Skip to content

Commit 29cef5b

Browse files
tbitcsoz-agent
andcommitted
feat: upgrade --full sync, template refactor (#45), PyPI integration (#36)
specsmith upgrade --full: - Regenerates exec shims (PID tracking/abort), CI configs, agent integrations — safe: never touches AGENTS.md, LEDGER.md, user docs - Creates missing community files (CONTRIBUTING.md, LICENSE, etc.) - Creates missing config files (.editorconfig, .gitattributes) - This is the mechanism for pushing specsmith fixes (like the exec shim locking fix) to existing governed projects Template refactor (#45): - Moved pyproject.toml.j2 to python/ subdirectory - Templates now organized: python/, rust/, go/, js/, community/, governance/, docs/, scripts/, workflows/ PyPI integration (#36): - Already implemented via #44 release.yml template with OIDC-based trusted publishing for Python projects Closes #36, #45 Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 476429b commit 29cef5b

4 files changed

Lines changed: 147 additions & 28 deletions

File tree

src/specsmith/cli.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,23 @@ def compress(project_dir: str, threshold: int, keep_recent: int) -> None:
291291
default=".",
292292
help="Project root directory.",
293293
)
294-
def upgrade(spec_version: str | None, project_dir: str) -> None:
295-
"""Update governance files to match a newer spec version."""
294+
@click.option(
295+
"--full",
296+
is_flag=True,
297+
default=False,
298+
help="Full sync: also regenerate exec shims, CI, agent files, create missing community files.",
299+
)
300+
def upgrade(spec_version: str | None, project_dir: str, full: bool) -> None:
301+
"""Update governance files to match a newer spec version.
302+
303+
With --full: also regenerates exec shims (PID tracking), CI configs,
304+
agent integrations, and creates missing community files. Safe: never
305+
overwrites AGENTS.md, LEDGER.md, or user documentation.
306+
"""
296307
from specsmith.upgrader import run_upgrade
297308

298309
root = Path(project_dir).resolve()
299-
result = run_upgrade(root, target_version=spec_version)
310+
result = run_upgrade(root, target_version=spec_version, full=full)
300311
console.print(result.message)
301312

302313
if result.updated_files:

src/specsmith/scaffolder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ def _build_file_map(config: ProjectConfig) -> list[tuple[str, str]]:
144144
ProjectType.BACKEND_FRONTEND,
145145
ProjectType.BACKEND_FRONTEND_TRAY,
146146
):
147-
files.append(("pyproject.toml.j2", "pyproject.toml"))
147+
files.append(("python/pyproject.toml.j2", "pyproject.toml"))
148148
files.append(("python/init.py.j2", f"src/{config.package_name}/__init__.py"))
149149
if config.type == ProjectType.CLI_PYTHON:
150150
files.append(("python/cli.py.j2", f"src/{config.package_name}/cli.py"))
File renamed without changes.

src/specsmith/upgrader.py

Lines changed: 132 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -47,16 +47,42 @@ class UpgradeResult:
4747
]
4848

4949

50+
def _get_env_and_ctx(
51+
config: ProjectConfig,
52+
) -> tuple[Environment, dict[str, object]]:
53+
"""Create Jinja env and template context from config."""
54+
from specsmith.tools import get_tools
55+
56+
env = Environment(
57+
loader=PackageLoader("specsmith", "templates"),
58+
autoescape=select_autoescape([]),
59+
keep_trailing_newline=True,
60+
trim_blocks=True,
61+
lstrip_blocks=True,
62+
)
63+
ctx: dict[str, object] = {
64+
"project": config,
65+
"today": date.today().isoformat(),
66+
"package_name": config.package_name,
67+
"tools": get_tools(config),
68+
}
69+
return env, ctx
70+
71+
5072
def run_upgrade(
5173
root: Path,
5274
*,
5375
target_version: str | None = None,
76+
full: bool = False,
5477
) -> UpgradeResult:
5578
"""Upgrade governance files to a newer spec version.
5679
5780
Args:
5881
root: Project root directory.
5982
target_version: Target spec version. If None, uses the current specsmith version.
83+
full: If True, also regenerate exec shims, agent integrations, CI configs,
84+
and create missing community/RTD files. Safe: never overwrites
85+
AGENTS.md, LEDGER.md, REQUIREMENTS.md, TEST_SPEC.md, or user docs.
6086
6187
Returns:
6288
UpgradeResult with details of the operation.
@@ -79,41 +105,22 @@ def run_upgrade(
79105
new_version = target_version or __version__
80106
old_version = config.spec_version
81107

82-
if old_version == new_version:
108+
# For --full, allow syncing even when version matches
109+
if old_version == new_version and not full:
83110
return UpgradeResult(message=f"Already at spec version {new_version}. Nothing to upgrade.")
84111

85-
# Update config
86112
config.spec_version = new_version
87-
88-
env = Environment(
89-
loader=PackageLoader("specsmith", "templates"),
90-
autoescape=select_autoescape([]),
91-
keep_trailing_newline=True,
92-
trim_blocks=True,
93-
lstrip_blocks=True,
94-
)
95-
96-
from specsmith.tools import get_tools
97-
98-
ctx = {
99-
"project": config,
100-
"today": date.today().isoformat(),
101-
"package_name": config.package_name,
102-
"tools": get_tools(config),
103-
}
113+
env, ctx = _get_env_and_ctx(config)
104114

105115
result = UpgradeResult()
106116

107117
# Migrate legacy lowercase filenames to uppercase
108118
_migrate_legacy_filenames(root, result)
109119

120+
# Regenerate governance templates (always overwritten — they're spec-managed)
110121
for template_name, output_rel in _GOVERNANCE_TEMPLATES:
111122
output_path = root / output_rel
112-
113-
if not output_path.exists():
114-
result.skipped_files.append(output_rel)
115-
continue
116-
123+
output_path.parent.mkdir(parents=True, exist_ok=True)
117124
tmpl = env.get_template(template_name)
118125
content = tmpl.render(**ctx)
119126
output_path.write_text(content, encoding="utf-8")
@@ -133,6 +140,10 @@ def run_upgrade(
133140
save_budget(root, CreditBudget())
134141
result.updated_files.append(".specsmith/credit-budget.json")
135142

143+
# Full sync: regenerate shims, CI, agent files, create missing community files
144+
if full:
145+
result.updated_files.extend(_sync_full(root, config, env, ctx))
146+
136147
result.message = (
137148
f"Upgraded from {old_version} to {new_version}. "
138149
f"{len(result.updated_files)} files updated, {len(result.skipped_files)} skipped."
@@ -141,6 +152,103 @@ def run_upgrade(
141152
return result
142153

143154

155+
# Files that are NEVER overwritten by --full sync (user-owned content)
156+
_USER_OWNED: set[str] = {
157+
"AGENTS.md",
158+
"LEDGER.md",
159+
"README.md",
160+
"docs/REQUIREMENTS.md",
161+
"docs/TEST_SPEC.md",
162+
"docs/ARCHITECTURE.md",
163+
"docs/WORKFLOW.md",
164+
}
165+
166+
167+
def _sync_full(
168+
root: Path,
169+
config: ProjectConfig,
170+
env: Environment,
171+
ctx: dict[str, object],
172+
) -> list[str]:
173+
"""Full sync: regenerate infrastructure files, create missing community files.
174+
175+
Safe rules:
176+
- User-owned docs (AGENTS.md, LEDGER.md, etc.) are NEVER touched
177+
- Exec shims are ALWAYS regenerated (they carry security/abort logic)
178+
- CI configs are regenerated (tool-aware, reflects current specsmith version)
179+
- Agent integrations are regenerated
180+
- Community/RTD files are created only if missing
181+
"""
182+
synced: list[str] = []
183+
184+
from specsmith.scaffolder import _build_community_files
185+
186+
# 1. Exec shims — always regenerate (carries PID tracking / abort fixes)
187+
shim_templates = [
188+
("scripts/exec.cmd.j2", "scripts/exec.cmd"),
189+
("scripts/exec.sh.j2", "scripts/exec.sh"),
190+
("scripts/setup.cmd.j2", "scripts/setup.cmd"),
191+
("scripts/setup.sh.j2", "scripts/setup.sh"),
192+
("scripts/run.cmd.j2", "scripts/run.cmd"),
193+
("scripts/run.sh.j2", "scripts/run.sh"),
194+
]
195+
for tmpl_name, output_rel in shim_templates:
196+
out = root / output_rel
197+
out.parent.mkdir(parents=True, exist_ok=True)
198+
tmpl = env.get_template(tmpl_name)
199+
out.write_text(tmpl.render(**ctx), encoding="utf-8")
200+
synced.append(output_rel)
201+
202+
# 2. Agent integrations — regenerate
203+
for integration_name in config.integrations:
204+
if integration_name == "agents-md":
205+
continue
206+
try:
207+
from specsmith.integrations import get_adapter
208+
209+
adapter = get_adapter(integration_name)
210+
files = adapter.generate(config, root)
211+
for f in files:
212+
synced.append(str(f.relative_to(root)))
213+
except ValueError:
214+
pass
215+
216+
# 3. VCS CI configs — regenerate
217+
if config.vcs_platform:
218+
try:
219+
from specsmith.vcs import get_platform
220+
221+
platform = get_platform(config.vcs_platform)
222+
files = platform.generate_all(config, root)
223+
for f in files:
224+
synced.append(str(f.relative_to(root)))
225+
except ValueError:
226+
pass
227+
228+
# 4. Community files — create only if missing
229+
for tmpl_name, output_rel in _build_community_files(config):
230+
out = root / output_rel
231+
if not out.exists():
232+
out.parent.mkdir(parents=True, exist_ok=True)
233+
tmpl = env.get_template(tmpl_name)
234+
out.write_text(tmpl.render(**ctx), encoding="utf-8")
235+
synced.append(f"{output_rel} (created)")
236+
237+
# 5. Config files — create only if missing (.editorconfig, .gitattributes)
238+
config_templates = [
239+
("editorconfig.j2", ".editorconfig"),
240+
("gitattributes.j2", ".gitattributes"),
241+
]
242+
for tmpl_name, output_rel in config_templates:
243+
out = root / output_rel
244+
if not out.exists():
245+
tmpl = env.get_template(tmpl_name)
246+
out.write_text(tmpl.render(**ctx), encoding="utf-8")
247+
synced.append(f"{output_rel} (created)")
248+
249+
return synced
250+
251+
144252
def _migrate_legacy_filenames(root: Path, result: UpgradeResult) -> None:
145253
"""Rename legacy lowercase governance files to uppercase.
146254

0 commit comments

Comments
 (0)