diff --git a/.claude/skills/bootstrap/SKILL.md b/.claude/skills/bootstrap/SKILL.md new file mode 100644 index 0000000..2482fd6 --- /dev/null +++ b/.claude/skills/bootstrap/SKILL.md @@ -0,0 +1,123 @@ +--- +name: bootstrap +description: Bootstrap this pipelex-starter-python template into a real project — replaces every placeholder name (my-project / my_project / "My Project" / TestMyProject), the package directory, description, author, repo URL and LICENSE holder, then regenerates the lock file and runs the checks and tests. Use this right after creating a repo from the template, or whenever the user says "bootstrap", "set up this template", "rename the project", "initialize the project", "replace the placeholders", "give this project a name", or "make this my own". +--- + +# Bootstrap Workflow + +This repo is a GitHub **template**. A fresh clone still has placeholder names everywhere: the distribution name `my-project`, the importable package `my_project` (a directory plus many references), the README title `My Project`, and the e2e test class `TestMyProject`. This skill turns those placeholders into the user's real project name in one reviewable pass, then proves the result still passes CI's gates. + +The mechanical replacement is done by a bundled script — `scripts/bootstrap.py` — because the same name appears in four spellings across code, config, docs, and the license, plus two filesystem renames. The script is deterministic and supports `--dry-run`, so you can show the plan before touching anything. **Your job in this skill is to collect good inputs, preview, run the script, and verify.** Walk the user through it; confirm before the steps that change files. + +## Step 1 — Preflight + +Confirm this is an un-bootstrapped template and the tree is clean enough to work in: + +1. Read the `name = "..."` line near the top of `pyproject.toml`. + - If it is `name = "my-project"`: this is a fresh template — continue. + - If it is anything else, or the `my_project/` directory is gone: it looks **already bootstrapped**. Tell the user and ask whether to proceed anyway (the script is safe to re-run but most edits will be no-ops). +2. Run `git status --short`. If the tree is dirty, mention it — bootstrap will add edits and renames on top, and the user asked for changes to be left **unstaged** for their own review, so a noisy starting point is worth flagging. + +## Step 2 — Collect the project details + +Ask the user for the following in one consolidated message. Lead with the package name (everything else derives from it) and offer sensible defaults so they can just confirm. + +**Required:** +- **Package name** (importable, underscores) — e.g. `invoice_extractor`. Must be lowercase letters/digits/underscores, starting with a letter. This becomes the package directory and every `import` / `library_dirs` reference. +- **Display title** — e.g. `Invoice Extractor`. Default: the package name title-cased. Goes in the README H1. +- **Description** — a one-liner for `pyproject.toml`. (Currently `"Replace this with your project description"`.) + +**Optional** (let them skip any): +- **Author** — fills the commented-out `authors = [...]` line in `pyproject.toml`. Ask for **both name and email**; if the user offers only one, explicitly ask for the other (an email with no name is a common omission — confirm the name rather than inventing one or silently pulling it from `git config`). +- **GitHub repository URL** — e.g. `https://github.com/acme/invoice-extractor`. Replaces the `yourusername/my-project` Repository URL and the README clone URLs. +- **License** — the template ships **MIT**. Ask which license the user wants, because switching type (not just the holder) touches three places — the `LICENSE` body, `license = "..."` in `pyproject.toml`, and the README license line — and the script handles all three so you don't have to edit them by hand. Offer: + - **Keep MIT** (default) — pass `--license-holder` (and optionally `--license-year`) to refresh the copyright line; the MIT body stays. + - **Proprietary / all rights reserved** — the script rewrites `LICENSE` to an "all rights reserved" notice and sets `license = "LicenseRef-Proprietary"`. Proprietary has **no SPDX id**, and `uv lock --locked` (CI) validates that field, so the `LicenseRef-` form is required — the script uses it automatically. Collect the copyright holder. + - **Other SPDX license** (e.g. `Apache-2.0`) — the script sets the `license =` field and README label and writes a `LICENSE` **stub**; warn the user they must paste the full license text in themselves (the script can't author arbitrary license bodies). + - **Copyright holder + year** — collect the holder for any non-default choice; the year **defaults to the current year** (the script reads the system clock — don't hardcode or assume it) and can be overridden with `--license-year`. + +Derive and show the four name forms so the user can sanity-check before anything runs: +- distribution (dashes): package with `_`→`-` (e.g. `invoice-extractor`) — override-able +- package (underscores): as given +- title: as given +- test class: `Test` + CamelCase of the package (e.g. `TestInvoiceExtractor`) + +If the user gives a title but no package name, slugify the title to underscores for the default package name and confirm it. + +## Step 3 — Preview (dry run) + +Before changing anything, run the script in dry-run mode and show the user the plan: + +```bash +python .claude/skills/bootstrap/scripts/bootstrap.py \ + --package "" \ + --title "" \ + --description "<description>" \ + [--author-name "<name>" --author-email "<email>"] \ + [--repo-url "<url>"] \ + [--license "mit|proprietary|<spdx-id>"] \ + [--license-holder "<holder>"] \ + [--license-year "<year>"] \ + --clean \ + --dry-run +``` + +`--license` defaults to `mit`; pass `proprietary` or an SPDX id when the user chose otherwise. `--license-year` defaults to the current year (read from the system clock) — only pass it to override. + +Pass `--clean` because the user opted to strip the template-only scaffolding (the README "Use this template / Next steps" block; the bootstrap skill itself is removed separately in Step 6). Omit it only if the user changed their mind and wants the template block kept. + +The dry run prints the renames and the list of files that would be edited. Present that summary and **get explicit confirmation** before the real run. Only pass `--dist` if the user wants a distribution name that isn't just the package with dashes. + +## Step 4 — Run the replacement + +Re-run the exact same command **without** `--dry-run`. The script: +- renames `my_project/` → `<package>/` and `tests/e2e/test_my_project.py` → `tests/e2e/test_<package>.py` (via `git mv`, so history follows) +- substitutes all four name spellings across `pyproject.toml`, `README.md`, `CLAUDE.md`, the package's `.py`/`.mthds` files, and the tests +- fills in description, and (if given) author, repo URL +- applies the license choice in all three places: the `LICENSE` body, `license = "..."` in `pyproject.toml`, and the README license line +- strips the README template block + +It deliberately does **not** run the lock file, run the checks, commit, or touch `.github/`, `.venv/`, `uv.lock`, or the existing `release` skill. + +**Heads-up — file state changed on disk.** The script rewrites `pyproject.toml`, `README.md`, and `LICENSE` (and `--clean` shifts README line numbers). If you find you need a manual `Edit` afterward, **re-read the file first** and re-derive any line numbers — a pre-run `grep` result is stale, and an `Edit` against an unread/old version will fail with "modified since read." In practice the script is meant to cover every placeholder so manual edits shouldn't be needed; if you reach for one, it's worth checking whether the script should handle that case instead. + +**Heads-up — staging is mixed.** `git mv` *stages* the renames (they show as `R` in `git status`), while the content edits stay unstaged (`M`). That's intentional — staged renames give the cleanest diff for review — but it means the change set is not uniformly unstaged. Nothing is committed. The user reviews everything with `git status` + `git diff` and commits when ready (a single `git add -A && git commit` captures both the staged renames and the unstaged edits). + +## Step 5 — Regenerate the lock file and verify + +The renamed distribution must be reflected in `uv.lock`, or CI's `package-check.yml` (`uv lock --locked`) fails the PR. Then run the same gates `lint-check.yml` and `tests-check.yml` enforce. All three are quiet on success: + +```bash +make li # uv lock + uv sync — refreshes uv.lock for the new project name +make agent-check # ruff format/lint, plxt, pyright, mypy +make agent-test # tests, excludes inference/LLM markers +``` + +- **On success**: report it and continue. +- **On failure**: show the output and fix the cause (a leftover reference, a stale import, a name that didn't get rewritten), then re-run. Don't move on with a red check — the PR's CI will be red too. `make agent-check` auto-formats, so it may itself modify files; that's expected. + +## Step 6 — Clean up the bootstrap scaffolding & hand off + +Bootstrap is a one-shot, so it removes itself **last**, only after the checks are green: + +```bash +rm -rf .claude/skills/bootstrap +``` + +Use a plain `rm` (not `git rm`) so the deletion stays unstaged, like the other content changes. + +Finally, give the user a short summary: +- the four name forms that were applied, and the license that was set +- that the package directory and e2e test file were renamed +- that `uv.lock` was regenerated and `make agent-check` / `make agent-test` pass +- that **nothing is committed**; the renames are staged (`R`) and the content edits are unstaged (`M`) — they should review with `git status` and `git diff`, then commit (a single `git add -A && git commit` captures everything) +- a nudge to skim the new `README.md` and write real project content, and to update `CLAUDE.md` if the project's specifics have changed + +## Rules + +- **Never commit; let the user review and commit.** Don't `git commit` or `git add` content edits. The renames go through `git mv` (so they're staged as clean `R` entries — that's fine and gives the best diff) while everything else, including the self-removal (`rm`, not `git rm`), stays unstaged. Tell the user the staging is mixed so the "review then commit" handoff isn't a surprise. +- **Always dry-run before the real run** and get confirmation. This edits a brand-new repo and renames a directory; the preview is cheap insurance. +- **Regenerate `uv.lock`.** Renaming the distribution name makes the lock stale; `make li` (or `uv lock`) is what keeps `package-check.yml` green. Don't skip it. +- **Don't stop on a red check.** A failing `make agent-check` / `make agent-test` here means CI will fail too — fix the root cause and re-run. +- **Don't touch the `release` skill or `.github/` workflows** — they're generic to the template and not placeholders. +- If any step fails or the user wants to abort, stop immediately and leave the tree in a state they can inspect — don't push forward through errors. diff --git a/.claude/skills/bootstrap/scripts/bootstrap.py b/.claude/skills/bootstrap/scripts/bootstrap.py new file mode 100644 index 0000000..cabf851 --- /dev/null +++ b/.claude/skills/bootstrap/scripts/bootstrap.py @@ -0,0 +1,500 @@ +#!/usr/bin/env python3 +"""Rename the pipelex-starter-python template placeholders to a real project. + +This is the deterministic engine behind the `/bootstrap` skill. It does the +mechanical, error-prone part — renaming the package directory, the e2e test +file, and substituting four different spellings of the project name across the +code, config, docs, and license — so the skill (and the human) can focus on +collecting good inputs and verifying the result. + +Why a script instead of a pile of Edit calls: the same name appears in four +forms (dash / underscore / Title Case / CamelCase) scattered across many files, +plus two filesystem renames. Doing that by hand once is fine; doing it reliably +every time someone clones the template is exactly what a script is for. It is +also safe to run with --dry-run, which is what makes it testable. + +The script only transforms files. It does NOT touch git, run `uv lock`, run the +checks, or remove the bootstrap skill — the SKILL.md orchestrates those so each +step stays reviewable and the script stays a pure, idempotent transform. +""" + +from __future__ import annotations + +import argparse +import datetime +import keyword +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +# The template's placeholders, in their four spellings. Everything the script +# does is ultimately "turn these into the user's chosen name". +TEMPLATE_DIST = "my-project" # distribution name (pyproject [project].name, PyPI-style) +TEMPLATE_PACKAGE = "my_project" # importable package / directory name +TEMPLATE_TITLE = "My Project" # human-facing display name (README H1) +TEMPLATE_CAMEL = "MyProject" # only ever seen inside the test class TestMyProject + +PACKAGE_RE = re.compile(r"^[a-z][a-z0-9_]*$") +# PEP 503/508 distribution name: alphanumerics separated by single '.', '-' or +# '_', starting and ending with an alphanumeric. uv/packaging reject anything +# else (e.g. a name with spaces), so we validate before writing [project].name. +DIST_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$") + + +@dataclass(frozen=True) +class Names: + """The four spellings of the new project name, derived from one another.""" + + dist: str # e.g. "invoice-extractor" + package: str # e.g. "invoice_extractor" + title: str # e.g. "Invoice Extractor" + camel: str # e.g. "InvoiceExtractor" + + +def camel_from_package(package: str) -> str: + return "".join(part.capitalize() for part in package.split("_") if part) + + +def title_from_package(package: str) -> str: + return " ".join(part.capitalize() for part in package.split("_") if part) + + +def validate_package(package: str) -> None: + """A package name has to be a real importable identifier, or nothing downstream works.""" + if not PACKAGE_RE.match(package): + sys.exit( + f"Invalid package name {package!r}: use lowercase letters, digits and underscores, starting with a letter (e.g. 'invoice_extractor')." + ) + if keyword.iskeyword(package): + sys.exit(f"Invalid package name {package!r}: it is a Python keyword.") + + +def validate_dist(dist: str) -> None: + """The distribution name lands in [project].name, which uv/packaging validate. + + --package is validated separately, and the default dist (package with + underscores swapped for dashes) is always valid — but --dist is an explicit + override that would otherwise reach pyproject.toml unchecked, so an input + like 'bad dist name' must be rejected here rather than failing `uv lock` on + the generated project. + """ + if not DIST_RE.match(dist): + sys.exit( + f"Invalid distribution name {dist!r}: use letters, digits, '.', '-' or '_', " + "starting and ending with a letter or digit, no spaces (e.g. 'invoice-extractor')." + ) + + +# --------------------------------------------------------------------------- # +# License handling +# --------------------------------------------------------------------------- # + +PROPRIETARY_LICENSE = """Copyright (c) {year} {holder} + +All rights reserved. + +This software and its associated documentation (the "Software") are the +proprietary and confidential property of {holder}. Unauthorized copying, +distribution, modification, public display, or use of the Software, in whole or +in part, via any medium, is strictly prohibited without the express prior +written permission of {holder}. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +""" + +OTHER_LICENSE = """Copyright (c) {year} {holder} + +This project is licensed under the {spdx} license. + +Replace this file with the full text of the {spdx} license — you can obtain it +from https://spdx.org/licenses/{spdx}.html +""" + + +@dataclass(frozen=True) +class License: + """The chosen license, in the forms the three call-sites need. + + `kind` drives the LICENSE body, `spdx` is the value for pyproject's + `license = "..."` field (which `uv lock --locked` validates, so a proprietary + project needs a `LicenseRef-` expression rather than free text), and + `holder`/`year` fill the copyright notice. + """ + + kind: str # "mit" | "proprietary" | "other" + spdx: str + holder: str | None + year: int + + +def resolve_license(value: str, holder: str | None, year: int) -> License: + norm = value.strip().lower() + if norm in ("", "mit"): + return License(kind="mit", spdx="MIT", holder=holder, year=year) + if norm in ("proprietary", "all-rights-reserved", "all rights reserved", "licenseref-proprietary"): + return License(kind="proprietary", spdx="LicenseRef-Proprietary", holder=holder, year=year) + # Anything else is treated as a raw SPDX id (e.g. Apache-2.0). We set the + # field and write a stub, but can't author arbitrary license text for them. + return License(kind="other", spdx=value.strip(), holder=holder, year=year) + + +# --------------------------------------------------------------------------- # +# File transforms +# --------------------------------------------------------------------------- # + + +def toml_str(value: str) -> str: + """Render value as a TOML basic string: surrounding quotes plus escaping. + + User-supplied fields (description, author name/email, license, repo URL) are + written straight into pyproject.toml, so a value containing a double-quote or + backslash — e.g. `Use "AI" agents` — would otherwise produce invalid TOML and + break `uv lock`/checks on the generated project. We escape backslash first + (so it doesn't double-escape the sequences added afterwards), then the quote + and the control characters TOML basic strings forbid bare. + """ + escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + return f'"{escaped}"' + + +def apply_name_tokens(text: str, names: Names) -> str: + """Substitute every spelling of the template name with the new one. + + The four tokens are mutually non-overlapping (no token is a substring of + another — "MyProject" only ever appears inside "TestMyProject", which we + handle as its own token), so the order of replacement does not matter. + """ + text = text.replace(f"Test{TEMPLATE_CAMEL}", f"Test{names.camel}") + text = text.replace(TEMPLATE_TITLE, names.title) + text = text.replace(TEMPLATE_PACKAGE, names.package) + text = text.replace(TEMPLATE_DIST, names.dist) + return text + + +def transform_pyproject(text: str, names: Names, opts: "Options") -> str: + # Description: a real one-liner beats "Replace this with your project description". + text = text.replace( + 'description = "Replace this with your project description"', + f"description = {toml_str(opts.description)}", + ) + + # License field: uv validates this against SPDX on `uv lock --locked`, so a + # proprietary project needs a `LicenseRef-` expression, not free text. + if opts.lic.spdx != "MIT": + text = text.replace('license = "MIT"', f"license = {toml_str(opts.lic.spdx)}") + + # Author: the line ships commented out. Fill in whatever we were given + # (name, email, or both) and uncomment it; otherwise leave the template comment. + author_inner = None + if opts.author_name and opts.author_email: + author_inner = f"{{ name = {toml_str(opts.author_name)}, email = {toml_str(opts.author_email)} }}" + elif opts.author_name: + author_inner = f"{{ name = {toml_str(opts.author_name)} }}" + elif opts.author_email: + author_inner = f"{{ email = {toml_str(opts.author_email)} }}" + if author_inner: + text = text.replace( + '# authors = [{ name = "Your Name", email = "your.email@example.com" }]', + f"authors = [{author_inner}]", + ) + + # Repository URL: if given, drop in the real URL and remove the reminder + # comment. If not, the token pass below rewrites my-project -> dist and we + # leave the `yourusername` placeholder + reminder comment in place on purpose. + if opts.repo_url: + # Double backslashes so re.sub's replacement mini-language treats any + # backslash from toml_str() as literal rather than a group reference. + repository_line = f"Repository = {toml_str(opts.repo_url)}".replace("\\", "\\\\") + text = re.sub( + r'Repository = "[^"]*"(?:\s*#.*)?', + repository_line, + text, + ) + + return apply_name_tokens(text, names) + + +def strip_template_block(text: str) -> str: + """Remove the README's template-only preamble (the 'Use this template' block). + + The block runs from the `*Replace "My Project" ...*` italic line down to the + `---` rule that closes it, just before `## Getting Started`. We anchor on + those two markers rather than line numbers so edits to the prose above don't + break us. + """ + lines = text.splitlines() + start = next((i for i, ln in enumerate(lines) if ln.startswith('*Replace "')), None) + if start is None: + return text # already stripped (e.g. re-run) — nothing to do + end = next((j for j in range(start + 1, len(lines)) if lines[j].strip() == "---"), None) + if end is None: + return text + del lines[start : end + 1] + out = "\n".join(lines) + if text.endswith("\n"): + out += "\n" + # Collapse the blank-line gap left behind so the H1 sits right above "## Getting Started". + return re.sub(r"\n{3,}", "\n\n", out) + + +def transform_readme(text: str, names: Names, opts: "Options") -> str: + if opts.clean: + text = strip_template_block(text) + + # Clone instructions: point them at the real repo if we have one. A common + # GitHub URL already ends in `.git`, so strip it before re-appending (no + # `.git.git`) and before deriving the directory name (`cd repo`, not `cd repo.git`). + if opts.repo_url: + bare_url = opts.repo_url.removesuffix(".git") + repo_name = bare_url.rsplit("/", 1)[-1] + text = text.replace( + "git clone https://github.com/yourusername/your-repo-name.git", + f"git clone {bare_url}.git", + ) + text = text.replace("cd your-repo-name", f"cd {repo_name}") + else: + text = text.replace("your-repo-name", names.dist) + + # License line: reflect the chosen license in the README's License section. + if opts.lic.kind == "proprietary": + text = text.replace( + "This project is licensed under the [MIT license](LICENSE).", + "This project is proprietary — all rights reserved. See the [LICENSE](LICENSE) file.", + ) + elif opts.lic.spdx != "MIT": + text = text.replace("[MIT license](LICENSE)", f"[{opts.lic.spdx} license](LICENSE)") + + return apply_name_tokens(text, names) + + +def transform_license(text: str, opts: "Options") -> str: + lic = opts.lic + if lic.kind == "mit": + # Keep the MIT text; only refresh the copyright line when we have a + # holder to put there (don't silently bump the template holder's year). + if lic.holder: + text = re.sub( + r"Copyright \(c\) \d{4} .*", + f"Copyright (c) {lic.year} {lic.holder}", + text, + count=1, + ) + return text + holder = lic.holder or "<COPYRIGHT HOLDER>" + if not lic.holder: + print("warning: no --license-holder given; wrote a placeholder into LICENSE.", file=sys.stderr) + if lic.kind == "proprietary": + return PROPRIETARY_LICENSE.format(year=lic.year, holder=holder) + print( + f"warning: wrote a LICENSE stub for '{lic.spdx}'. Replace it with the full {lic.spdx} license text.", + file=sys.stderr, + ) + return OTHER_LICENSE.format(year=lic.year, holder=holder, spdx=lic.spdx) + + +def transform_generic(text: str, names: Names) -> str: + return apply_name_tokens(text, names) + + +# --------------------------------------------------------------------------- # +# Filesystem renames +# --------------------------------------------------------------------------- # + + +def git_available(root: Path) -> bool: + return (root / ".git").exists() and shutil.which("git") is not None + + +def move(root: Path, src: Path, dst: Path, opts: "Options") -> None: + """Rename src -> dst, using `git mv` when possible so history follows the file.""" + rel_src = src.relative_to(root) + rel_dst = dst.relative_to(root) + if opts.dry_run: + print(f" rename {rel_src} -> {rel_dst}") + return + if opts.use_git: + result = subprocess.run( + ["git", "-C", str(root), "mv", str(rel_src), str(rel_dst)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print(f" renamed (git) {rel_src} -> {rel_dst}") + return + print(f" git mv failed ({result.stderr.strip()}); falling back to plain rename") + os.rename(src, dst) + print(f" renamed {rel_src} -> {rel_dst}") + + +# --------------------------------------------------------------------------- # +# Orchestration +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Options: + description: str + author_name: str | None + author_email: str | None + repo_url: str | None + lic: License + clean: bool + dry_run: bool + use_git: bool + + +def gather_target_files(root: Path, names: Names) -> list[Path]: + """The explicit set of files that may contain the name. Kept narrow on + purpose: we never sweep .venv, uv.lock, .git, .github or .pipelex traces.""" + candidates: list[Path] = [ + root / "pyproject.toml", + root / "README.md", + root / "CLAUDE.md", + root / "LICENSE", + ] + pkg_dir = root / names.package + candidates += sorted(pkg_dir.rglob("*.py")) + candidates += sorted(pkg_dir.rglob("*.mthds")) + candidates += sorted((root / "tests").rglob("*.py")) + seen: set[Path] = set() + files: list[Path] = [] + for path in candidates: + if path.exists() and path.is_file() and path not in seen: + seen.add(path) + files.append(path) + return files + + +def write_file(path: Path, root: Path, original: str, updated: str, opts: Options) -> bool: + if original == updated: + return False + rel = path.relative_to(root) + if opts.dry_run: + print(f" edit {rel}") + else: + path.write_text(updated, encoding="utf-8") + print(f" edited {rel}") + return True + + +def transform_for(path: Path, text: str, names: Names, opts: Options) -> str: + name = path.name + if name == "pyproject.toml": + return transform_pyproject(text, names, opts) + if name == "README.md": + return transform_readme(text, names, opts) + if name == "LICENSE": + return transform_license(text, opts) + return transform_generic(text, names) + + +def run(root: Path, names: Names, opts: Options) -> None: + # Guard: confirm this actually is the unbootstrapped template before we + # start renaming things. Cheap check, saves a confusing half-applied state. + pyproject = root / "pyproject.toml" + if not pyproject.exists(): + sys.exit(f"No pyproject.toml found in {root} — run this from the project root.") + pyproject_text = pyproject.read_text(encoding="utf-8") + if f'name = "{TEMPLATE_DIST}"' not in pyproject_text: + print( + f'warning: pyproject.toml does not contain name = "{TEMPLATE_DIST}". This repo may already be bootstrapped; proceeding anyway.', + file=sys.stderr, + ) + + print(f"Bootstrapping template -> {names.title!r}") + print(f" dist={names.dist} package={names.package} camel={names.camel}") + if opts.dry_run: + print(" (dry run — no files will be modified)") + print() + + # 1. Renames first, so the file transforms below see the final paths. + print("Renames:") + old_pkg = root / TEMPLATE_PACKAGE + new_pkg = root / names.package + if names.package != TEMPLATE_PACKAGE and old_pkg.is_dir(): + move(root, old_pkg, new_pkg, opts) + old_test = root / "tests" / "e2e" / f"test_{TEMPLATE_PACKAGE}.py" + new_test = root / "tests" / "e2e" / f"test_{names.package}.py" + if names.package != TEMPLATE_PACKAGE and old_test.exists(): + move(root, old_test, new_test, opts) + if opts.use_git and not opts.dry_run and names.package != TEMPLATE_PACKAGE: + print(" note: git mv stages the renames; the content edits below are left unstaged.") + print() + + # During a dry run the renames did not actually happen, so read the file + # list from the original paths to still show a meaningful plan. + scan_names = names if not opts.dry_run else Names(names.dist, TEMPLATE_PACKAGE, names.title, names.camel) + + # 2. Content edits. + print("Edits:") + changed = 0 + for path in gather_target_files(root, scan_names): + original = path.read_text(encoding="utf-8") + updated = transform_for(path, original, names, opts) + if write_file(path, root, original, updated, opts): + changed += 1 + if changed == 0: + print(" (no content changes)") + print() + print(f"Done. {changed} file(s) {'would be ' if opts.dry_run else ''}edited.") + if not opts.dry_run: + print("\nNext: regenerate the lock file (uv.lock pins the project name) and run the checks:") + print(" make li && make agent-check && make agent-test") + print("Then review with `git status` and `git diff` before committing.") + + +def parse_args(argv: list[str]) -> argparse.Namespace: + p = argparse.ArgumentParser(description="Replace pipelex-starter-python template placeholders.") + p.add_argument("--package", required=True, help="Importable package name, underscores (e.g. invoice_extractor)") + p.add_argument("--title", help="Display title (default: derived from --package, e.g. 'Invoice Extractor')") + p.add_argument("--dist", help="Distribution name, dashes (default: derived from --package)") + p.add_argument("--description", required=True, help="One-line project description for pyproject.toml") + p.add_argument("--author-name", help="Author name for pyproject.toml authors field") + p.add_argument("--author-email", help="Author email for pyproject.toml authors field") + p.add_argument("--repo-url", help="GitHub repository URL, e.g. https://github.com/acme/invoice-extractor") + p.add_argument("--license", help="License: 'mit' (default), 'proprietary', or an SPDX id like 'Apache-2.0'") + p.add_argument("--license-holder", help="Copyright holder for the LICENSE notice") + p.add_argument("--license-year", type=int, help="Copyright year for the LICENSE notice (default: current year)") + p.add_argument("--clean", action="store_true", help="Strip the README template-only block") + p.add_argument("--dry-run", action="store_true", help="Print the plan without modifying anything") + p.add_argument("--root", default=".", help="Project root (default: current directory)") + return p.parse_args(argv) + + +def main(argv: list[str]) -> None: + args = parse_args(argv) + package = args.package.strip() + validate_package(package) + dist = (args.dist or package.replace("_", "-")).strip() + validate_dist(dist) + title = (args.title or title_from_package(package)).strip() + names = Names(dist=dist, package=package, title=title, camel=camel_from_package(package)) + + root = Path(args.root).resolve() + license_year = args.license_year or datetime.date.today().year + lic = resolve_license( + args.license or "mit", + (args.license_holder or "").strip() or None, + license_year, + ) + opts = Options( + description=args.description.strip(), + author_name=(args.author_name or "").strip() or None, + author_email=(args.author_email or "").strip() or None, + repo_url=(args.repo_url or "").strip().rstrip("/") or None, + lic=lic, + clean=args.clean, + dry_run=args.dry_run, + use_git=git_available(root), + ) + run(root, names, opts) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebb5da6..0ebd048 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [v0.9.1] - 2026-06-09 + +- Add a `/bootstrap` skill (`.claude/skills/bootstrap/`) that turns a fresh clone of this template into a real project. It collects the project name, description, author, repository URL, and license, then renames the package directory and e2e test file, substitutes every placeholder name spelling (dash / underscore / Title Case / CamelCase), applies the chosen license (MIT, proprietary, or another SPDX id) across `LICENSE`, `pyproject.toml`, and the README, regenerates `uv.lock`, and runs the lint/type checks and tests + ## [v0.9.0] - 2026-06-06 - Bump `pipelex` to `v0.32.0`: See `Pipelex` changelog [here](https://docs.pipelex.com/latest/changelog/) diff --git a/pyproject.toml b/pyproject.toml index 4e17a9c..9e62c6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "my-project" -version = "0.9.0" +version = "0.9.1" description = "Replace this with your project description" # authors = [{ name = "Your Name", email = "your.email@example.com" }] license = "MIT" diff --git a/uv.lock b/uv.lock index cbafecf..7d6bd13 100644 --- a/uv.lock +++ b/uv.lock @@ -1732,7 +1732,7 @@ wheels = [ [[package]] name = "my-project" -version = "0.9.0" +version = "0.9.1" source = { virtual = "." } dependencies = [ { name = "pipelex", extra = ["anthropic", "bedrock", "fal", "google", "google-genai", "mistralai"] },