Skip to content
bmsuissePublic

About

CLI Utils we use in many projects

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

bmsdna-devtools

Shared developer tooling for BMS projects: PR build/check status and a one-shot PR summary (bdt pr info), PR creation, claiming an issue (bdt issue take), issue/work item creation and comments, git worktrees (creation and merged-worktree/orphaned-test-DB cleanup), a commit-and-push helper with pre-flight checks, Azure log queries, and static checks (bdt lint) for postgres/psycopg SQL rules, pydantic-model placement, hand-wired HTTP access in TypeScript and baseline tooling, plus bdt dead-code for .sql files nothing loads and backend routes nothing calls. bdt pr * and bdt issue * auto-detect whether the current repo's origin remote is Azure DevOps or GitHub and use az/gh accordingly. Consolidates near-duplicate scripts that used to be copy-pasted across OneSales, ccmt2, and MDMApp into one versioned package with a bdt CLI.

Requires git always, plus az (Azure DevOps commands, and all bdt logs commands) and/or gh (GitHub commands) on PATH as needed — each is checked lazily, only when a command actually needs it, with a clear error and an install link if missing rather than a raw traceback. Works on Windows: CLI shims (e.g. az.cmd) are resolved via shutil.which (which honors PATHEXT) rather than shelling out, output is decoded as UTF-8 rather than relying on the console's default codepage, and file arguments accept either slash style.

Install

uv tool install bmsdna-devtools

Or as a project dependency: uv add bmsdna-devtools.

bdt pr status

Find the PR opened from the current branch and report build/check status (failed steps print their logs inline). Works against Azure DevOps or GitHub — whichever origin points at.

bdt pr status [--target-branch BRANCH] [--wait]

The --target / --target-branch option of pr create/retry/publish/update/comment/watch-deploy and commit defaults to dev; pass --target main (or whatever your PRs go into) to override. pr status, pr info and issue take only look a PR up, so by default they don't filter on the target branch at all.

If the PR can't be merged, that's reported immediately instead of polling for builds/checks that will never run — e.g. on Azure DevOps: PR #42 ('feat: widgets') has merge conflicts with the target branch (mergeStatus=conflicts); on GitHub: PR #42 ('feat: widgets') has merge conflicts with 'main' (mergeable=CONFLICTING). Exit code 1 either way.

With --wait, a build/check that's paused on a manual approval (an Azure Pipelines stage's Checkpoint.Approval, or a GitHub Actions deployment protection rule) ends the wait instead of polling forever — it prints which stage/check needs a reviewer plus a link to act on it, and exits 3 (bdt pr watch-deploy --wait, below, behaves the same way). If another build/check has already failed, that's reported instead (exit 1) even when one is also waiting on approval.

Azure DevOps: org/project/repo are auto-detected from git remote get-url origin (handles SSH, dev.azure.com HTTPS, and *.visualstudio.com HTTPS forms). Auth is an explicit PAT (--pat or AZURE_DEVOPS_EXT_PAT/AZURE_DEVOPS_PAT env var), falling back to a short-lived token from the caller's own az login — never embed a PAT literal in a script or CI file. --target-branch optionally restricts the lookup to PRs into that branch (default: any).

GitHub: uses gh's own auth (gh auth login) and always resolves the PR opened from the current branch — gh pr view has no target-branch filter, so --target-branch is ignored here; the PR's actual base branch is shown in the output. Check status is computed from gh pr view --json statusCheckRollup rather than gh pr checks --json, since the latter flag isn't available in all gh releases.

Pass --pr-id <number> to check a specific PR directly instead of resolving it from the current branch — useful when that PR's branch isn't checked out locally at all. bdt pr retry, pr publish, pr update, and pr comment all accept the same option, for the same reason. (pr watch-deploy doesn't: it watches a branch-triggered build/workflow run, not any particular PR.)

bdt pr info

bdt pr info [--json] [--pr-id N] [--target-branch BRANCH]

One-shot summary of the current branch's PR: link, state (open/merged/closed, draft), aggregate build state (passing, failing, pending, waiting for a manual approval, none) and the issue(s)/work item(s) it closes, with links. Exits 1 when the branch has no PR. --json is what a status integration reads (e.g. the Claude Code mod planned in bmsuisse/skills#61). Build state is judged the way bdt pr status does: on GitHub from the check rollup (only a failed check fails it; skipped/cancelled ones don't count), on Azure DevOps from the latest build of each pipeline (any failed result fails it; unfinished builds are pending, or waiting when a stage needs approval). unknown if Azure DevOps can't be asked.

Azure DevOps limits: the PR is found by the current branch (into --target-branch if given, else any target), a PR with merge conflicts is reported as an error (exit 1) like pr status does, and the closed work items are only those whose URL is in the PR description -- not ones linked via bdt pr create --issue.

bdt pr retry

Retry only the failed job(s)/stage(s) of the most recent build/run for the PR opened from the current branch — not a whole new build/run. Whenever bdt pr status reports a failure, it prints a hint to run this.

bdt pr retry [--target-branch dev]

No build/run ID needed — like bdt pr status, it resolves the PR (and its latest build/run per pipeline/workflow) from the current branch, or from --pr-id directly if given.

Azure DevOps: uses the retry=true query parameter on the "Update Build" REST API (PATCH .../_apis/build/builds/{buildId}?retry=true&api-version=7.1), which reschedules whichever stage(s)/job(s) failed on the previous attempt (plus anything depending on them) in place — distinct from queuing a brand new build. One retry call per pipeline that has a failed build for the PR. --target-branch selects which PR to look at, same as bdt pr status.

GitHub: uses gh run rerun <run-id> --failed, which reruns only the failed job(s) (and their dependents) of a workflow run — the run ID(s) are found automatically from the PR's failing checks. Checks that aren't backed by a GitHub Actions run (e.g. a legacy commit status from an external CI) can't be retried this way and are skipped; if none of the failing checks are retryable, the command exits with an error.

bdt pr watch-deploy

Many pipelines have a second build/stage that only runs on the target branch once a PR merges into it, and that's the one that actually deploys. bdt pr status only watches builds/checks tied to the PR itself (its merge/source ref or GitHub's statusCheckRollup), so it never sees that second build. bdt pr watch-deploy does: it finds the most recent build/ workflow run triggered directly on --target-branch and reports its status the same way pr status does (failed steps print their logs inline).

bdt pr watch-deploy [--target-branch dev] [--wait]

After bdt pr status reports the PR's build/checks succeeded, if a build/ run has already been triggered on the target branch (e.g. the merge already kicked one off), it prints a hint suggesting this command. That check is best-effort — a failure reading it (auth, permissions, network) is silently skipped rather than blocking or crashing pr status.

Azure DevOps: looks up builds via the same _apis/build/builds endpoint pr status uses, just queried by branchName=refs/heads/<target-branch> instead of the PR's merge/source ref.

GitHub: uses gh run list --branch <target-branch> --event push, which selects workflow runs triggered by a push to that branch — as opposed to a pull_request-triggered run for some still-open PR targeting the same branch. Failed steps are printed via gh run view <id> --log-failed.

bdt pr create

bdt pr create --target dev   # or --target main / test

Creates a PR from the current branch into --target. On Azure DevOps, a thin wrapper around az repos pr create (org/project/repo inferred by az itself from the git remote). On GitHub, gh pr create --fill (autofills title/body from commit info so it never blocks on an interactive prompt). Extra arguments pass through either way, e.g. bdt pr create --target dev -- --title "...".

PRs are created as drafts by default; a successful create always prints the PR's link plus bdt pr publish (which abstracts over the host) to mark it ready for review. Pass --no-draft to open it ready for review immediately instead.

--label (repeatable) applies labels to the PR on either host: on GitHub these map to gh pr create --label, so the label must already exist on the repo (gh label create); on Azure DevOps they map to az repos pr create --labels, which are freeform and get created on the fly. A successful create prints the PR's web/GUI link (not just the REST API URL Azure DevOps' az output otherwise gives you).

[tool.bdt.pr.required_labels] in pyproject.toml can require at least one label from each named group before the PR is created — checked locally (no gh/az call happens if a group isn't satisfied):

[tool.bdt.pr.required_labels]
type = ["bug", "feature", "chore"]
risk = ["breaking", "non-breaking"]

With the above, bdt pr create --label feature --label breaking passes, but bdt pr create --label feature fails with a message naming the unmet group (risk) and its allowed choices.

[tool.bdt.pr.scope_labels] in pyproject.toml can auto-apply a label based on the "scope" of the HEAD commit's conventional-commit subject (the type(scope): description format bdt commit itself expects, e.g. feat(customers): ...):

[tool.bdt.pr.scope_labels]
customers = "e2e-customers"
billing = "e2e-billing"

With the above, creating a PR whose HEAD commit is feat(customers): add widget automatically adds the e2e-customers label (merged with, not replacing, any explicit --label). No match, no [tool.bdt.pr.scope_labels] table, or a commit subject without a (scope) all mean no label gets added — opt-in, so repos that don't configure it see no change in behavior.

If --target has a build policy configured (an Azure DevOps Build policy, or a GitHub branch protection rule requiring status checks), a successful create prints a reminder to run bdt pr status afterward to check whether the CI build passes. This is a best-effort check — failures reading policy config (auth, permissions) fail open and simply skip the reminder.

bdt issue create / update / delete, bdt issue comment add / update / delete

bdt issue create --title "Nightly job fails" --description "..." --type Bug --screenshot before.png
bdt issue update 1234 --state Resolved --tag fixed
bdt issue delete 1234 --yes

bdt issue comment add 1234 --message "Repro'd, see attached" --screenshot repro.png
bdt issue comment update 1234 5678 --message "Actually, see the second screenshot"
bdt issue comment delete 1234 5678 --yes

Creates/updates/deletes an issue (GitHub) or work item (Azure DevOps), and adds/edits/deletes comments on one, auto-detected from origin like bdt pr *. bdt issue comment add prints the new comment's ID so you can pass it to update/delete later.

Destructive commands (issue delete, issue comment delete) require an explicit --yes — there's no interactive confirmation prompt, since bdt is also invoked by AI-agent callers that can't answer one.

--tag and --label are aliases of each other (repeatable on create, search, and update) — pass whichever reads naturally; the values are merged and routed to tags on Azure DevOps or labels on GitHub, whichever backend is actually active, instead of being silently ignored by the other one. Same for update's --remove-tag/--remove-label.

Azure DevOps: --type selects the work item type on create (Bug, Task, User Story, ... — whatever the project's process defines; default Bug). --tag/--label set/replace the full tag list (repeatable; omit both on update to leave tags unchanged). update's --remove-tag/ --remove-label instead removes just the named tag(s) and leaves the rest — unlike --tag/--label, which replace the whole set — by reading the work item's current tags first (there's no Azure DevOps "remove one tag" patch op). --state (update only) sets System.State, e.g. Active, Resolved, Closed. --screenshot uploads each image as a work item attachment (visible in the Attachments tab) and posts a comment embedding them inline with Markdown — the Description field defaults to HTML via the REST API, where a raw ![]() would just show as literal text, but the work item Discussion/Comments control has always rendered Markdown. issue delete soft-deletes to the project's Recycle Bin (restorable, not permanent).

--board <team> sets the work item's Area Path to that Azure Boards team's default, so it shows up on that team's board — a CLI flag beats [tool.bdt.ado].board in pyproject.toml, which beats filing under the project's root area:

[tool.bdt.ado]
board = "My Team"

On update, --board only moves the item when you pass it explicitly — it never falls back to pyproject.toml, so an unrelated field update (e.g. just --title) can't silently relocate the item to a different board.

GitHub: a thin wrapper around gh issue create / edit / delete / comment. --label/--tag add a label on create, or add/remove one on update (paired with --remove-label/--remove-tag); labels must already exist in the repo. --screenshot pushes images to a pr-assets branch (same trick bdt pr create --screenshot uses, since GitHub has no API for uploading an image into an issue) and appends them to the issue body / comment as Markdown. issue delete is permanent — GitHub has no recycle bin for issues. Comment update/delete go through gh api directly (gh issue has no subcommand for editing/deleting an arbitrary comment by ID). Extra arguments to bdt issue create pass through to gh issue create, e.g. bdt issue create --title "..." -- --assignee @me.

--board <name> on create adds the issue to that GitHub Projects (v2) board by title (needs a token with the project scope — gh auth refresh -s project). On search, --board (title or number) scopes results to issues currently on that board; since gh issue list has no board filter of its own, this fetches a wider raw pool and filters it down client-side, so a board with few matches may return fewer results than --limit asks for. A CLI flag beats [tool.bdt.github].board in pyproject.toml:

[tool.bdt.github]
board = "Roadmap"

bdt issue do

bdt issue do 60 [--agent claude] [--dry-run] [extra agent args...]

Hands an issue (GitHub) or work item (Azure DevOps) to a coding agent. Fetches the title and description and runs claude -p "<prompt>" --name "60: <title>", so the session is easy to find with claude --resume. --agent picks another executable (it then gets just the prompt as its last argument); extra args are passed through; --dry-run prints the command.

It takes the issue first (like bdt issue take): before the agent starts it comments Taken by <you> on the issue, naming the session it is about to start -- for claude a --session-id is generated and passed, so the comment says claude --resume <id>; the prompt tells the agent not to take it again. For another --agent the comment just says (via <agent>). --dry-run posts nothing. Like issue take, it doesn't comment again when the newest comment already is your claim; if it is someone else's, it stops (exit 1) unless --force.

bdt issue take

bdt issue take [NUMBER] [--target-branch BRANCH] [--force]

Claims an issue (GitHub) or work item (Azure DevOps): comments Taken by <you> on it, followed by the running coding agent's session (the same <Agent> Session: <id> note every bdt comment gets: a claude.ai link under a bridged Claude session, otherwise the bare session id; nothing outside an agent), so others can see who is on it. <you> is the GitHub login gh is authenticated as, else the git user.name. Without NUMBER it takes the issue the current branch's PR closes (Fixes #N, or an issue/work-item URL in the PR body); it refuses to guess if that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it says so and posts nothing, so it's safe to run repeatedly -- including before bdt issue do.

bdt translate

One translations.toml is the source of truth for all UI translations; bdt translate generates the per-language de.json / en.json / ... files from it. The generated files are build artifacts -- add them to .gitignore and run bdt translate in your just install / build / CI steps.

# translations.toml (keys with dots must be quoted when `nested = true`)
[ADD_BUTTON]
en = "Add"
de = "Hinzufügen"
fr = "Ajouter"
it = "Aggiungi"
server_only = true   # optional: never emitted into the JSON files (backend-only text)
# pyproject.toml
[tool.bdt.translate]
file = "translations.toml"             # default
languages = ["en", "de", "fr", "it"]   # default
required_languages = ["en"]            # default; `bdt translate add` fails without these
output = ["frontend/src/assets/i18n"]  # directories receiving <lng>.json
nested = false                         # true: "a.b.c" keys become nested JSON objects
scan = ["frontend/src"]                # t("KEY") / $t("KEY") in .ts/.tsx/.js/.jsx/.vue
scan_jinja = ["backend/print"]         # "KEY" | tr in .jinja2/.j2/.html
  • A missing language falls back to de, then en, then the key itself.
  • Keys used in code but absent from translations.toml are appended with an English placeholder and the command exits 1 without generating anything -- fill in the translations and rerun.
  • bdt translate add KEY en=Add de=Hinzufügen fr=Ajouter it=Aggiungi adds a key to translations.toml and regenerates all JSON files in one go (the required_languages are mandatory; --force overwrites an existing key).
  • bdt translate --check writes nothing and exits 1 if code uses keys missing from the toml (for CI).
  • bdt translate --import merges existing <lng>.json files into translations.toml once, to migrate a repo that so far hand-maintained its JSON files; then git rm --cached them and ignore them.

bdt worktree

bdt worktree my-feature [--base dev] [--env-file .local_env] [--no-submodules] [--install "just install"]

Creates .worktrees/<name> branched from --base, initializes submodules (unless --no-submodules), and copies an env file into the new worktree as .env (auto-detects .local_env then .env if --env-file isn't given).

bdt find-repo

bdt find-repo my-repo [--root ~/projects] [--org MYORG] [--github-org MYORG] [--yes] [--pat PAT]

Finds a repo by name: first among the local clones under the work dir, then -- only if there is no local match -- in an Azure DevOps org and/or a GitHub org. Names match exactly (case-insensitive) if any repo has that name, otherwise by substring. Local matches are printed as paths. A single remote-only match is offered for cloning (--yes skips the prompt; without a TTY it never prompts and just tells you to pass --yes); several matches are listed and nothing is cloned.

Clone destinations: <root>/<project>/<repo> for Azure DevOps, <root>/github/<repo> for GitHub.

Configuration (flags override env vars):

Setting Flag Env vars Default
Local work dir --root AZDO_WORK_DIR, BMS_WORK_DIR ~/projects (C:/Projects on Windows)
Azure DevOps org --org AZDO_ORG, BMS_ORG none (ADO not searched)
GitHub org --github-org GITHUB_ORG, BMS_GITHUB_ORG none (GitHub not searched)
ADO PAT for cloning --pat AZURE_DEVOPS_EXT_PAT, AZURE_DEVOPS_PAT falls back to az login

Set the org env vars in your shell profile to make bdt find-repo <name> work anywhere. It replaces the cross-repo-discovery skill's ALL_REPOS.md sync. Needs az (ADO) / gh (GitHub) only when those orgs are searched.

bdt cleanup worktrees / bdt cleanup orphaned-dbs / bdt cleanup db / bdt cleanup worktree

bdt cleanup worktrees [root] [--remote origin] [--keep-dbs] [--yes]
bdt cleanup orphaned-dbs [root] [--include-caution] [--yes]
bdt cleanup db [path] [--confirm]
bdt cleanup worktree [path] [--keep-db] [--confirm]

The first two recursively scan every git repo under root (default: .) for worktrees. bdt cleanup worktrees prunes the ones fully merged into <remote>/main/<remote>/test (falling back to local main/test if no such remote refs exist) — e.g. a tree of .worktrees/<branch> directories accumulated across several repos over time. bdt cleanup orphaned-dbs has no --remote/merge-status notion at all: it just finds pgdevkit test DBs with no matching live git worktree, regardless of whether that worktree was ever merged anywhere. Like bdt issue delete, neither command has an interactive prompt — both only ever print what they would remove/drop; pass --yes to actually do it.

bdt cleanup db / bdt cleanup worktree instead target one specific, still-live worktree — path defaults to ., so both are meant to be run from inside the worktree in question, regardless of its merge status. cleanup db only drops that worktree's own pgdevkit test DB(s), leaving the worktree itself alone; cleanup worktree removes the worktree too (and, unless --keep-db, its DB(s) along with it) — refusing the main checkout, a protected branch (main/test), a locked worktree, or a dirty one (submodules included). Since these two act on a single worktree a human picked out by hand, rather than scanning for candidates, they default to an interactive y/N confirmation instead of --yes; pass --confirm to skip it for non-interactive use.

Every one of these four commands, before dropping any database, additionally requires typing yes at an interactive prompt whenever --pg-host isn't localhost/127.0.0.1/::1 — this specific check has no flag to bypass it (not even --yes/--confirm), so a script or agent can never drop a database on a shared/remote Postgres instance without a human confirming it directly.

The DB-naming algorithm and orphan detection are entirely pgdevkit's own (pgdevkit.testdb.workspace_db_names() / pgdevkit.testdb.find_orphaned_dbs()) — this used to be a hand-rolled reimplementation here (to avoid an import), which risked drifting out of sync with pgdevkit's actual naming; now that pgdevkit exposes both directly, bdt just calls them. If a repo's root pyproject.toml has a [tool.pgdevkit].engine = "postgres" (the default once [tool.pgdevkit] exists at all), removing one of its worktrees also drops the Postgres test database(s) pgdevkit created for that branch — pass --keep-dbs to skip that. A DB whose name ends in a bare branch name (main/test/dev/head/i18n, rather than a slugified feature branch) is flagged ⚠ possibly a standing reference DB and excluded even with --yes, since that might be an intentional baseline DB rather than an orphaned leftover — pass --include-caution too if you've verified it really is safe to drop. That flagging (bdt's own heuristic, not pgdevkit's) is the one bit of naming-adjacent logic still here.

A repo can additionally own sibling test DBs (e.g. a second DB for a vendored mock service) and nested ones (an unrelated per-branch DB, under a different pgdevkit project name, that happens to share the same branch). Siblings are pgdevkit's own concern now — configure [tool.pgdevkit].extra_db_suffixes in the consuming repo (see pgdevkit's README) and both ensure_testdb-side tooling and bdt cleanup pick it up automatically. Nested projects have no pgdevkit equivalent (it's a wholly separate project name/pyproject.toml, not a literal suffix of the same project's DB), so that stays configured here:

[tool.bdt.worktree]
db_nested_projects = ["akeneo_editor"]  # a wholly separate per-branch DB,
                                         # its own (possibly section-less)
                                         # pyproject.toml, sharing this
                                         # worktree's branch

--pg-port/--pg-user (all four commands; env vars PGPORT/PGUSER, no fallback to $USER/$LOGNAME — those are set in virtually every shell, which would make the pgdevkit-user default below never fire) default to pgdevkit's own test-container port/user, not the OS user or Postgres' standard 5432 — bdt cleanup orphaned-dbs's listing step always connects via pgdevkit's own PGDEVKIT_TESTDB_*-driven resolution (it's calling straight into pgdevkit), so its own psql-based DROP step defaults to matching that, rather than silently targeting a different Postgres instance than the one that was just queried. Pass --pg-port/--pg-user explicitly if your setup deliberately differs.

bdt commit

bdt commit "feat(x): add widget support" file1.py file2.py [--json] [--no-verify] [--subrepo database]

Stages, commits, and pushes the given files. Pre-flight checks: files exist, commit message follows Conventional Commits (type(scope): description, skip with --skip-message-check), not on main/master (skip with --allow-main). Retries once (re-git add) if a pre-commit hook reformats files. Pass --subrepo <dir> (repeatable) for repos that vendor a submodule (e.g. database) — files under that prefix are committed/pushed inside the submodule first, then the bump is staged in the parent repo.

The built-in commit types are feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert. A repo can accept additional types on top of those under [tool.bdt.commit] in pyproject.toml:

[tool.bdt.commit]
types = ["sql", "infra"]

Scope (the (x) in feat(x): ...) is unrestricted by default — any scope, or none at all, is accepted. A repo can opt into restricting it to a fixed list under the same table:

[tool.bdt.commit]
scopes = ["api", "ui", "db"]

Once configured, a message that names a scope must use one from the list — but a message with no scope at all is still always accepted; this doesn't make a scope mandatory.

If the pushed commit's type is feat and the current branch's PR is already published (not a draft), it's converted back to draft — a feature needs a fresh review pass before CI/merge, not just whatever review happened before the commit existed. Run bdt pr publish when it's ready again. Pass --target/--pat to resolve the PR on Azure DevOps (GitHub always resolves the current branch's PR directly).

Set IS_BMS_AI_SANDBOX=1 to skip the push step (commit only) — used when an AI coding sandbox pushes on its own schedule separately. The draft conversion above only runs after an actual push, so it's skipped in sandbox mode too.

--json emits a machine-readable result for AI-agent callers:

{
  "success": true, "committed": true, "pushed": true,
  "message": "...", "files": ["..."], "commit_sha": "abc1234",
  "error": null, "hint": null, "commit_type": "feat", "commit_scope": "x"
}

bdt logs roles / bdt logs tail

Query Application Insights (KQL over traces/exceptions) via az monitor app-insights query. No defaults are baked in — pass --resource-group/--app-insights explicitly (or set AZURE_RESOURCE_GROUP/AZURE_APP_INSIGHTS), since which Azure resource "this repo" maps to isn't derivable from the git remote.

bdt logs roles --resource-group my-rg --app-insights my-app-insights --minutes 60
bdt logs tail --resource-group my-rg --app-insights my-app-insights --role my-service --level warning

bdt logs fetch

Downloads the App Service log archive for a webapp/slot via az webapp log download, unzips it, and writes every line matching a common error/warning marker (ERROR, CRITICAL, WARNING, tracebacks, 4xx/5xx, FAILED, FATAL) to <out>/<slot>_errors.log. Simpler and often preferable to the KQL commands above when you just want "what broke recently" rather than a queryable trace stream.

The webapp/resource-group/slot come from a named environment configured in the calling repo's pyproject.toml. slot is optional — omit it for an app's default/production slot (no --slot is passed to az); set it for a named deployment slot:

[tool.bdt.envs.prod]
webapp = "my-webapp"
resource_group = "my-rg"

[tool.bdt.envs.test]
webapp = "my-webapp"
resource_group = "my-rg"
slot = "test"
bdt logs fetch --env prod
bdt logs fetch --env prod --out logs/ --keep-archive

bdt lint

Static checks (implementing bmsuisse/skills#52) for the postgres-best-practices skill's SQL rules, pydantic-model placement, hand-wired HTTP in TypeScript, and that the repo has its baseline tooling actually set up. Unused .sql files and dead backend routes are a separate command, bdt dead-code:

bdt lint                       # scan the current directory, recursively
bdt lint backend/              # scan one directory
bdt lint backend/db/a.py b.py  # scan only these files -- e.g. from a prek/pre-commit
                                # hook's staged-file list, so it can run on the diff only
bdt lint --no-tooling-check    # skip the tooling-config check for this run

Every .execute()/.executemany() call whose SQL argument can be resolved to a literal or f-string/concatenation/%-format expression is checked (an unresolvable argument, e.g. a plain function parameter, is silently skipped -- this can't false-positive on non-psycopg .execute() calls, or on dynamic SQL it can't see through):

  • sql-inline-too-complex — more than a trivial (≤4 line) query, or a JOIN/CTE/subquery/aggregation, inline instead of load_sql()/a .sql file. Simple INSERT/UPDATE/DELETE are exempt from the line limit; INSERT ... SELECT, UPDATE ... FROM and DELETE ... USING still count as complex. Also applied to any *_sql/*_SQL variable assigned a literal, even if it never reaches an .execute() call in the same file (e.g. it's handed to a helper).
  • sql-fstring-injection / sql-concat-injection / sql-percent-format-injection / sql-format-injection — SQL built with an f-string, + concatenation, the % operator, or str.format() instead of a psycopg t-string (3.14+), psycopg.sql, or bound params. For an f-string the fix is usually just f"..." -> t"..." ({value} is bound, {name:i} quotes an identifier).
  • sql-positional-param — positional %s instead of named %(name)s.
  • sql-forbidden-join — RIGHT JOIN/LATERAL JOIN/CROSS APPLY (same patterns the prek skill's check_files.py forbids in .sql files).

A candidate is only ever flagged once its (resolved) text actually parses with sqlglot as a SELECT/INSERT/UPDATE/ DELETE/UNION/MERGE — this is what lets bdt lint scan any .execute() call, regardless of driver, without flagging e.g. a duckdb COPY ... TO export.

pydantic-model-misplaced — a pydantic model (or PostgresTableModel) with more than 5 fields defined directly under an api/ directory, instead of an api/models/(/schemas//dto/) module.

ts-handwired-http / ts-handwired-model (TypeScript, .ts/.tsx/.mts, bmsuisse/devtools#52) — a frontend package that already generates an API client from the backend's OpenAPI schema (its nearest package.json depends on openapi-typescript/openapi-fetch, @hey-api/openapi-ts, orval, ... or has an openapi script) should use it. The rule flags hand-wired fetch(...), axios/axios.create(...) and new XMLHttpRequest() (ts-handwired-http), and a .json() result typed by casting/annotating a hand-written model (ts-handwired-model, only reported outside the block of a fetch/axios call, which the http rule already covers). Packages without a generator are skipped, as are generated code (generated/, *.gen.ts, *.generated.ts, api-types.ts, *.d.ts; also their .mts/.tsx variants), tests (*.test.*, *.spec.*, __tests__/, e2e/, tests/) and third-party URLs (fetch("https://...")). Hand-wired access is fine for files and other non-JSON traffic, since generators handle those badly, so a call is not flagged when its enclosing block mentions FormData, Blob/.blob()/ .arrayBuffer(), getReader()/response.body/TextDecoder/EventSource/SSE, createObjectURL, new File(, an application/octet-stream/multipart// text/event-stream content type, or returns .text() (a plain-text response), or sits in a createClient(...) setup (a fetch passed to the generated client). It's a tokenizer-level heuristic, not a type-aware analysis; to silence a legitimate exception, put a comment on (or right above) the line:

// bdt-lint: ignore ts-handwired-http -- websocket handshake, not in the schema

Tooling config — the repo must declare ty, ruff and pytest as dependencies, have pytest configured ([tool.pytest.ini_options] or a pytest.ini/setup.cfg), and have a prek.toml (see the prek skill). Never a hard block — bypass it for one run with --no-tooling-check, or permanently for the repo via pyproject.toml:

[tool.bdt.lint]
skip_tooling_check = true

Other [tool.bdt.lint] knobs (all optional): exclude_dirs (extra directory names to skip, beyond the built-in .venv/node_modules/etc. list), pydantic_field_threshold (default 5), pydantic_base_classes (default ["BaseModel", "PostgresTableModel"]), pydantic_allowed_subdirs (default ["models", "schemas", "dto"]), pydantic_api_dir_names (default ["api"]), ts_exclude_globs (repo-relative globs of TypeScript files to skip, e.g. ["src/legacy/*"]), ts_non_json_markers (extra strings that mark a call's enclosing block as non-JSON traffic).

Requires sqlglot for the SQL checks — already pulled in transitively via pgdevkit[db], but declared explicitly as the bmsdna-devtools[lint] extra; a clear install hint is printed (not a raw ImportError) if it's ever missing.

bdt dead-code

Dead code a linter can't see, in one command and one config table, [tool.bdt.dead_code]:

  • sql -- .sql files that no Python code loads;
  • routes -- backend (FastAPI) routes that neither non-generated frontend code nor a url_for(...) call uses.

Each check runs when it is configured (sql_roots, resp. [[tool.bdt.dead_code.apps]]); --only sql / --only routes (repeatable) narrows a run, e.g. to keep the slow one -- routes imports the app, so run bdt with the repo's own interpreter (uv run bdt dead-code) -- out of a prek hook. Exit code is 0 (clean), 1 (findings) or 2 (setup problem: invalid config or pyproject.toml, nothing configured, the app doesn't import within 5 minutes -- the error shows the import's output).

sql -- unreferenced .sql files

A .sql file that no Python code loads is a query left behind after its caller was deleted or renamed. Name the folders that hold loadable SQL (not schema/migration scripts, which are applied rather than loaded) and every file under them that nothing references is reported (sql-file-unreferenced):

[tool.bdt.dead_code]
sql_roots = ["backend/db/queries"]            # repo-relative; a typo'd root is itself reported
sql_loader_functions = ["load_sql"]           # default; add your own loader's name if it differs
sql_unreferenced_ignore = ["backend/db/queries/legacy/*.sql"]   # globs for files reached some other way
# exclude_dirs = ["generated"]                # extra directory names to skip, beyond .venv/node_modules/etc.

A file counts as referenced by a literal load_sql("topic", "name") call (positional or topic=/name=; topic = the file's parent directory, name = its stem); by a load_sql("topic", some_var) call in a Python file that also contains the stem as a string literal (so name = "a" if x else "b" and lookup tables work); by a string literal that is a path whose trailing segments equal the file's repo-relative path (get_sql_with_prm_list("backend/api/sql/x.sql")); or by its bare filename as a literal in a Python file under the SQL folder's parent (_SQL_DIR / "x.sql"). It never executes code, so a file reached through a fully computed path is a false positive -- list it in sql_unreferenced_ignore. References are searched across the whole repo, since the caller can live anywhere. A Python file that can't be parsed (including syntax newer than the interpreter running bdt) is itself reported as sql-check-python-unparseable, because references in it are unknown.

routes -- backend routes nobody uses

Backend (FastAPI) operations that no non-generated frontend code calls and no backend code references by name -- dead routes that still have to be maintained, secured and tested.

[[tool.bdt.dead_code.apps]]
name = "akeneo"                                   # label used in the report
app = "main_app:app"                              # module:attribute, imported in a subprocess ...
app_dir = "akeneo_editor/backend"                 # ... with this directory (repo-relative) as cwd/import root
# openapi = "frontend/openapi.json"               # alternative to `app`: a committed OpenAPI document
frontends = ["akeneo_editor/frontend/src"]        # repo-relative dirs or globs, e.g. "mdmapp/app/react_apps/*/src"
exclude_prefixes = ["/external_api"]              # routes meant for other callers (external API, webhooks)
exclude_tags = ["agent"]                          # e.g. LLM/MCP tools
exclude_paths = ["/auth/*", "GET /health"]        # fnmatch globs on "/path" or "METHOD /path"
baseline = "dead-code-baseline.txt"               # optional ratchet, see below
# env = { SOME_REQUIRED_SETTING = "x" }           # extra environment for importing the app
# exclude_frontend_globs = ["src/legacy/*"]       # repo-relative frontend files to ignore as callers
# url_for_functions = ["url_for", "url_path_for"] # default; functions whose first argument names a route

Pair each backend app with its own frontends (one [[...apps]] entry per backend) -- a route called only by another backend's frontend is still unused here.

The backend inventory is app.openapi() of the app and of every mounted sub-app (with the mount prefix), so it needs no committed schema and can't go stale; routes with include_in_schema=False are not considered.

Generated code never counts as a caller. A generated client lists every route, so it is skipped (generated/, *.gen.*, *.generated.*, api-types*, openapi_schema*, *.d.ts), as are tests and e2e specs (*.test.*, *.spec.*, __tests__/, tests/, e2e/) -- for TypeScript, JavaScript and Vue files alike. Comments are blanked before scanning. An operation is called when non-generated code of one of the app's frontends has (strongest first):

  • sdk -- a reference to a hey-api SDK function (read from the sdk.gen.ts under that same frontend directory, matched on method and url) or one of its react-query helpers (fooOptions, fooMutation, fooQueryKey, ...). Each frontends directory is scoped to its own SDK, so two apps that both generate listItems aren't confused;
  • fetch -- an openapi-fetch call .GET("/path" naming this method and path (a DELETE of the same path with no call of its own is still reported);
  • url -- a string/template literal equal to the path template (any method), e.g. a hand-written fetch(/api/x/${id}), "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/api/x/" + id or an <a href=...> -- nested templates like ${qs ? ?a=${b} : ""} are handled;
  • url-sfx -- a literal (two or more segments) that is a suffix of the route (a client with a base URL, or a ${base} prefix).

Routes the backend refers to by name are used, too. Auth redirects and OAuth callbacks have no frontend caller by nature -- the backend builds their URL: request.url_for("auth_callback"), app.url_path_for("login"), {{ url_for('login') }} in a template. A string literal passed (first argument, or name=) to one of url_for_functions in a non-test Python file or Jinja-style template (.html, .jinja, .jinja2, .j2) under the app's app_dir marks the route of that name as used ("mount:name" for a mounted sub-app counts as name). The route name is recovered from its operationId: FastAPI's default name + path + method form, a bare name, or <prefix>-<name> from a custom generate_unique_id; an OpenAPI file without operation ids can't be matched this way. It is a text match, so a call in a comment counts too.

It is a heuristic: no type information, a URL literal matches every method of that path (and so does an SPA <Link to="/users/${id}">), and URLs assembled from non-literal pieces (including a non-literal route name in url_for) or handed to the client by the server are invisible -- exclude those routes explicitly. An app that exposes no documented operations (e.g. one wrapped in middleware that hides openapi()) is a setup error, not a clean pass. Run against our repos, the usual legitimate exclusions are health checks, the SPA catch-all, service-to-service endpoints and an external API folder.

Adopting it without fixing everything first: set baseline, run uv run bdt dead-code --update-baseline once (it concerns the routes check only), and commit the file. From then on only new unused routes fail -- and a baseline line whose route has since been deleted, excluded or used is reported as api-route-baseline-stale, so the list can only shrink.

bdt find-injection

Scans backend and frontend code for injection risks, and warns when a web project has no Content-Security-Policy.

bdt find-injection [PATHS...]            # files/folders, default: current directory
bdt find-injection --diff [--base main]  # only files changed on this branch + uncommitted/untracked
bdt find-injection --strict              # also fail on "review" items

Findings have two severities: error (a definite unsafe pattern, exit code 1) and review (depends on where a value comes from; listed with an instruction for an AI/human to verify, exit code 0 unless --strict).

  • SQL (Python .execute()): f-string / % / concatenation / .format() SQL is an error. SQL from load_sql(), sql.SQL, sqlglot (expr.sql(), sqlglot.*), cast(LiteralString, <sqlglot expr>) (the cast is only as safe as its argument) or a function in the same file annotated -> LiteralString is trusted; SQL from any other function call is a sql-unverified-call review item. (bdt lint accepts the same trusted forms but never reports unverified calls.)
  • Other Python sinks: eval/exec, os.system, subprocess(..., shell=True), yaml.load without a safe loader, pickle.loads, Markup()/mark_safe(), Jinja autoescape=False, render_template_string.
  • Frontend (TS/JS/JSX/Vue/Svelte/HTML): innerHTML/outerHTML/insertAdjacentHTML, dangerouslySetInnerHTML, v-html, document.write, eval, new Function, string setTimeout/setInterval, javascript: URLs, postMessage(..., "*"), srcdoc, and <iframe> without sandbox (or with allow-scripts + allow-same-origin).
  • CSP: csp-missing when a web project (frontend files or a Python web framework) has no Content-Security-Policy anywhere in the repo (header in code, <meta http-equiv>, staticwebapp.config.json, nginx/web.config, ...); csp-weakened for 'unsafe-inline', 'unsafe-eval' or a wildcard script-src. The CSP lookup always covers the whole repo, even with --diff.

Skipped automatically: test files, generated code, *.min.js files, the usual build/vendor directories (node_modules, .venv, dist, ...) and, inside a git repo, anything ignored by .gitignore (pass --no-gitignore to scan it anyway). Add more with --exclude (repeatable; a directory name at any depth, a root-relative path, or a glob such as 'assets/**/*.js') or [tool.bdt.lint] exclude_dirs = [...] in pyproject.toml. Identical findings on the same line are reported once. Silence a confirmed-safe finding with # bdt-lint: ignore <rule> (Python) or // bdt-lint: ignore <rule> (TS/JS) on, or directly above, the line.

Releasing

Bump version in pyproject.toml as part of your PR, same as any other change. Once that PR merges to main and the Python Test workflow passes for that commit, .github/workflows/auto-release.yml automatically tags it vX.Y.Z, cuts a GitHub Release (skipping if that version was already released, e.g. a merge that didn't touch the version), and dispatches python-publish.yml to publish it to PyPI — no manual release step, and no extra secret to configure. Two non-obvious GitHub Actions quirks shaped this (see the comments at the top of auto-release.yml for the full reasoning, since both were hit and confirmed the hard way):

  • A release created with the default GITHUB_TOKEN does not trigger other workflows' release: published listeners (an anti-recursion safeguard) — workflow_dispatch is the documented exception, so auto-release.yml dispatches python-publish.yml directly (gh workflow run) instead of relying on the release to cascade into it.
  • python-publish.yml deliberately stays a plain, directly-triggered top-level workflow rather than something auto-release.yml calls via workflow_call: PyPI's OIDC trusted publishing does not support reusable/called workflows and silently rejects the token in that shape.

workflow_dispatch (or an actual GitHub UI release) on python-publish.yml still works as a manual fallback if you ever need to re-publish a version without going through auto-release.yml.

About

CLI Utils we use in many projects

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages