bmsdna-devtools 0.31.0__tar.gz → 0.32.0__tar.gz

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 (88) hide show
  1. bmsdna_devtools-0.31.0/README.md → bmsdna_devtools-0.32.0/PKG-INFO +21 -0
  2. bmsdna_devtools-0.31.0/PKG-INFO → bmsdna_devtools-0.32.0/README.md +7 -14
  3. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/cli.py +22 -0
  4. bmsdna_devtools-0.32.0/bmsdna/devtools/env_files.py +123 -0
  5. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/pyproject.toml +1 -1
  6. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/skills/bmsdna-devtools/SKILL.md +8 -2
  7. bmsdna_devtools-0.32.0/tests/test_env_files.py +92 -0
  8. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/uv.lock +1 -1
  9. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/.github/workflows/auto-release.yml +0 -0
  10. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/.github/workflows/python-publish.yml +0 -0
  11. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/.github/workflows/python-test.yml +0 -0
  12. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/.gitignore +0 -0
  13. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/.python-version +0 -0
  14. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/__init__.py +0 -0
  15. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/_api_dump.py +0 -0
  16. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/ado_auth.py +0 -0
  17. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/ado_issue.py +0 -0
  18. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/api_usage.py +0 -0
  19. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/app_service_logs.py +0 -0
  20. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/bdt_config.py +0 -0
  21. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/cli_tools.py +0 -0
  22. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/commit.py +0 -0
  23. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/dead_code.py +0 -0
  24. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/env_config.py +0 -0
  25. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/find_injection.py +0 -0
  26. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/find_repo.py +0 -0
  27. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gh_issue.py +0 -0
  28. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gh_pr.py +0 -0
  29. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/gitrepo.py +0 -0
  30. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/injection_frontend.py +0 -0
  31. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/injection_python.py +0 -0
  32. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/issue_do.py +0 -0
  33. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint.py +0 -0
  34. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_findings.py +0 -0
  35. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_models.py +0 -0
  36. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_sql.py +0 -0
  37. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_sql_files.py +0 -0
  38. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_tooling.py +0 -0
  39. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/lint_typescript.py +0 -0
  40. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/logs.py +0 -0
  41. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_build.py +0 -0
  42. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_info.py +0 -0
  43. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_issue_link.py +0 -0
  44. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_labels.py +0 -0
  45. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pr_markdown.py +0 -0
  46. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/pull.py +0 -0
  47. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/testdb.py +0 -0
  48. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/translate.py +0 -0
  49. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/bmsdna/devtools/worktree.py +0 -0
  50. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/justfile +0 -0
  51. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/conftest.py +0 -0
  52. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_ado_auth.py +0 -0
  53. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_ado_issue.py +0 -0
  54. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_ado_issue_flow.py +0 -0
  55. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_api_usage.py +0 -0
  56. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_app_service_logs.py +0 -0
  57. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_bdt_config.py +0 -0
  58. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_cleanup.py +0 -0
  59. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_commit.py +0 -0
  60. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_issue.py +0 -0
  61. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pr_create.py +0 -0
  62. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pr_id.py +0 -0
  63. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_pull.py +0 -0
  64. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_cli_tools.py +0 -0
  65. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_commit.py +0 -0
  66. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_env_config.py +0 -0
  67. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_find_injection.py +0 -0
  68. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_find_repo.py +0 -0
  69. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_gh_issue.py +0 -0
  70. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_gh_pr.py +0 -0
  71. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_gitrepo.py +0 -0
  72. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_issue_do.py +0 -0
  73. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint.py +0 -0
  74. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint_models.py +0 -0
  75. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint_sql.py +0 -0
  76. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint_sql_files.py +0 -0
  77. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint_tooling.py +0 -0
  78. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_lint_typescript.py +0 -0
  79. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_build.py +0 -0
  80. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_build_flow.py +0 -0
  81. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_info.py +0 -0
  82. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_issue_link.py +0 -0
  83. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_labels.py +0 -0
  84. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pr_markdown.py +0 -0
  85. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_pull.py +0 -0
  86. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_testdb.py +0 -0
  87. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_translate.py +0 -0
  88. {bmsdna_devtools-0.31.0 → bmsdna_devtools-0.32.0}/tests/test_worktree_clean.py +0 -0
@@ -1,3 +1,17 @@
1
+ Metadata-Version: 2.5
2
+ Name: bmsdna-devtools
3
+ Version: 0.32.0
4
+ Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: pgdevkit[db]>=0.7.1
7
+ Requires-Dist: requests>=2.32.0
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: tomli-w>=1.0.0
10
+ Requires-Dist: typer>=0.26.7
11
+ Provides-Extra: lint
12
+ Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
+ Description-Content-Type: text/markdown
14
+
1
15
  # bmsdna-devtools
2
16
 
3
17
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -329,6 +343,13 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
329
343
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
330
344
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
331
345
 
346
+ ## `bdt env get-keys`
347
+
348
+ Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
349
+ current directory (recursively, skipping `node_modules`, `.git`, `.venv`, `.worktrees` and `.claude`) and directly in `~`, so you (or an agent) can see which variables exist without `cat .env`.
350
+ `--search TEXT` filters by key name (case-insensitive); `--no-home` leaves out the files in `~`. Files in `~` -- and any file using
351
+ `export VAR=VALUE` -- are bash syntax, so the output adds a `source <file>` hint. Exits 1 if nothing matches.
352
+
332
353
  ## `bdt translate`
333
354
 
334
355
  One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: bmsdna-devtools
3
- Version: 0.31.0
4
- Summary: Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects
5
- Requires-Python: >=3.14
6
- Requires-Dist: pgdevkit[db]>=0.7.1
7
- Requires-Dist: requests>=2.32.0
8
- Requires-Dist: rich>=13.0.0
9
- Requires-Dist: tomli-w>=1.0.0
10
- Requires-Dist: typer>=0.26.7
11
- Provides-Extra: lint
12
- Requires-Dist: sqlglot>=27.0.0; extra == 'lint'
13
- Description-Content-Type: text/markdown
14
-
15
1
  # bmsdna-devtools
16
2
 
17
3
  Shared developer tooling for BMS projects: PR build/check status and a
@@ -343,6 +329,13 @@ branch's PR closes (`Fixes #N`, or an issue/work-item URL in the PR body); it re
343
329
  that is none or several. If the issue's newest comment already is a "Taken by ..." claim (by anyone) it
344
330
  says so and posts nothing, so it's safe to run repeatedly -- including before `bdt issue do`.
345
331
 
332
+ ## `bdt env get-keys`
333
+
334
+ Lists the variable **names** (never the values) defined in `.env`, `*.env` and `.env.*` files under the
335
+ current directory (recursively, skipping `node_modules`, `.git`, `.venv`, `.worktrees` and `.claude`) and directly in `~`, so you (or an agent) can see which variables exist without `cat .env`.
336
+ `--search TEXT` filters by key name (case-insensitive); `--no-home` leaves out the files in `~`. Files in `~` -- and any file using
337
+ `export VAR=VALUE` -- are bash syntax, so the output adds a `source <file>` hint. Exits 1 if nothing matches.
338
+
346
339
  ## `bdt translate`
347
340
 
348
341
  One `translations.toml` is the source of truth for all UI translations; `bdt translate` generates the
@@ -17,6 +17,7 @@ from pgdevkit.testdb import constants as pgdevkit_constants
17
17
 
18
18
  from . import ado_issue, api_usage as api_usage_mod, app_service_logs, commit as commit_mod, dead_code as dead_code_mod
19
19
  from . import env_config
20
+ from . import env_files as env_files_mod
20
21
  from . import find_repo as find_repo_mod
21
22
  from . import issue_do as issue_do_mod
22
23
  from . import gh_issue, gh_pr
@@ -74,6 +75,9 @@ app.add_typer(pr_app, name="pr")
74
75
  translate_app = typer.Typer(name="translate", help="Generate <lng>.json files from translations.toml")
75
76
  app.add_typer(translate_app, name="translate")
76
77
 
78
+ env_app = typer.Typer(name="env", help="Inspect .env files without exposing their values")
79
+ app.add_typer(env_app, name="env")
80
+
77
81
  issue_app = typer.Typer(name="issue", help="Issue / work item commands (Azure DevOps or GitHub, auto-detected from the git remote)")
78
82
  app.add_typer(issue_app, name="issue")
79
83
 
@@ -1209,6 +1213,24 @@ def translate_cmd(
1209
1213
  typer.echo(f"Wrote {len(res.written)} file(s)")
1210
1214
 
1211
1215
 
1216
+ @env_app.command("get-keys")
1217
+ def env_get_keys(
1218
+ search: str | None = typer.Option(None, "--search", "-s", help="Only keys containing this text (case-insensitive)"),
1219
+ no_home: bool = typer.Option(False, "--no-home", help="Don't also list the env files directly in ~"),
1220
+ ) -> None:
1221
+ """List variable names (never values) from .env, *.env and .env.* files under the current directory (and in ~).
1222
+
1223
+ Skips node_modules, .git, .venv, .worktrees and .claude.
1224
+ """
1225
+ home = None if no_home else Path.home()
1226
+ cwd = Path.cwd()
1227
+ files = env_files_mod.get_keys(cwd, home, search)
1228
+ if not files:
1229
+ typer.echo("No matching env files/keys found.", err=True)
1230
+ raise typer.Exit(1)
1231
+ typer.echo(env_files_mod.format_keys(files, home, cwd))
1232
+
1233
+
1212
1234
  @translate_app.command("add")
1213
1235
  def translate_add(
1214
1236
  key: str = typer.Argument(..., help="Translation key, e.g. ADD_BUTTON"),
@@ -0,0 +1,123 @@
1
+ """List the variable *names* defined in dotenv-style files, never their values.
2
+
3
+ Lets an agent (or human) find out which variables exist without `cat .env` /
4
+ `printenv` putting secrets into the transcript.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ _KEY_RE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=")
15
+ # Never descended into: dependency/VCS/tooling trees that are huge and never hold project env files.
16
+ SKIP_DIRS = frozenset({"node_modules", ".git", ".venv", ".worktrees", ".claude"})
17
+
18
+ _EXPORT_RE = re.compile(r"^\s*export\s+[A-Za-z_]")
19
+
20
+
21
+ @dataclass
22
+ class EnvFile:
23
+ path: Path
24
+ keys: list[str] = field(default_factory=list)
25
+ uses_export: bool = False # bash syntax (`export VAR=VALUE`): meant to be sourced
26
+ in_home: bool = False
27
+
28
+
29
+ def _is_env_name(name: str) -> bool:
30
+ return name == ".env" or name.endswith(".env") or name.startswith(".env.")
31
+
32
+
33
+ def _walk_env_files(root: Path) -> list[Path]:
34
+ """Env files under root, pruning SKIP_DIRS (and not following symlinked dirs) so big trees stay fast."""
35
+ found: list[Path] = []
36
+ for dirpath, dirnames, filenames in os.walk(root):
37
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS)
38
+ base = Path(dirpath)
39
+ found.extend(base / n for n in sorted(filenames) if _is_env_name(n) and (base / n).is_file())
40
+ return found
41
+
42
+
43
+ def find_env_files(cwd: Path, home: Path | None = None) -> list[tuple[Path, bool]]:
44
+ """`.env`, `*.env` and `.env.*` under cwd (recursive, skipping SKIP_DIRS) and directly in home.
45
+
46
+ Returns (path, in_home). When cwd is home itself it is not walked recursively.
47
+ """
48
+ found: list[tuple[Path, bool]] = []
49
+ seen: set[Path] = set()
50
+
51
+ def add(p: Path, in_home: bool) -> None:
52
+ real = p.resolve()
53
+ if real not in seen:
54
+ seen.add(real)
55
+ found.append((p, in_home))
56
+
57
+ if home is not None and cwd.resolve() == home.resolve():
58
+ cwd_files = [p for p in sorted(cwd.iterdir()) if _is_env_name(p.name) and p.is_file()]
59
+ else:
60
+ cwd_files = _walk_env_files(cwd)
61
+ for p in cwd_files:
62
+ add(p, False)
63
+ if home is not None:
64
+ for p in sorted(home.iterdir()):
65
+ if _is_env_name(p.name) and p.is_file():
66
+ add(p, True)
67
+ return found
68
+
69
+
70
+ def parse_env_file(path: Path, in_home: bool = False) -> EnvFile:
71
+ res = EnvFile(path=path, in_home=in_home)
72
+ try:
73
+ text = path.read_text(encoding="utf-8", errors="replace")
74
+ except OSError:
75
+ return res
76
+ for line in text.splitlines():
77
+ if line.lstrip().startswith("#"):
78
+ continue
79
+ m = _KEY_RE.match(line)
80
+ if m:
81
+ if m.group(1) not in res.keys:
82
+ res.keys.append(m.group(1))
83
+ if _EXPORT_RE.match(line):
84
+ res.uses_export = True
85
+ return res
86
+
87
+
88
+ def get_keys(cwd: Path, home: Path | None = None, search: str | None = None) -> list[EnvFile]:
89
+ """Env files with their key names; with `search`, only keys containing it (case-insensitive)."""
90
+ out: list[EnvFile] = []
91
+ for path, in_home in find_env_files(cwd, home):
92
+ ef = parse_env_file(path, in_home)
93
+ if search:
94
+ ef.keys = [k for k in ef.keys if search.lower() in k.lower()]
95
+ if not ef.keys:
96
+ continue
97
+ out.append(ef)
98
+ return out
99
+
100
+
101
+ def _display(path: Path, cwd: Path | None, home: Path | None) -> Path:
102
+ for base, prefix in ((cwd, None), (home, Path("~"))):
103
+ if base is None:
104
+ continue
105
+ try:
106
+ rel = path.relative_to(base)
107
+ except ValueError:
108
+ continue
109
+ return prefix / rel if prefix else rel
110
+ return path
111
+
112
+
113
+ def format_keys(files: list[EnvFile], home: Path | None = None, cwd: Path | None = None) -> str:
114
+ """Paths under cwd are shown relative to it, home files as ~/..., so the `source` hint works as printed."""
115
+ lines: list[str] = []
116
+ for ef in files:
117
+ shown = _display(ef.path, None if ef.in_home else cwd, home if ef.in_home else None)
118
+ lines.append(f"{shown}:")
119
+ if ef.in_home or ef.uses_export:
120
+ src = f"./{shown}" if not shown.is_absolute() and not str(shown).startswith("~") else shown
121
+ lines.append(f" # bash syntax, load with: source {src}")
122
+ lines.extend(f" {k}" for k in ef.keys)
123
+ return "\n".join(lines)
@@ -10,7 +10,7 @@ packages = ["bmsdna"]
10
10
 
11
11
  [project]
12
12
  name = "bmsdna-devtools"
13
- version = "0.31.0"
13
+ version = "0.32.0"
14
14
  description = "Shared Azure DevOps / GitHub / git / Azure Monitor developer tooling for BMS projects"
15
15
  readme = "README.md"
16
16
  requires-python = ">=3.14" # pgdevkit>=0.7.1 requires 3.14; was >=3.11 before adding it as a dependency
@@ -6,11 +6,11 @@ description: >
6
6
  a PR, creating a git worktree, committing and pushing files (with pre-flight
7
7
  checks), claiming an issue / work item (`bdt issue take`), a one-shot PR summary
8
8
  (`bdt pr info`: link, build state, closed issues), querying Azure logs, and static checks (`bdt lint`: SQL/psycopg rules, pydantic placement, tooling; `bdt dead-code`:
9
- unreferenced .sql files and backend routes nothing calls). `bdt pr *` works against both Azure DevOps
9
+ unreferenced .sql files and backend routes nothing calls), listing which env vars a repo defines (`bdt env get-keys`, names only). `bdt pr *` works against both Azure DevOps
10
10
  and GitHub — it auto-detects which one from the `origin` remote. Trigger
11
11
  whenever the user asks to check a build/PR status, create a PR, make a
12
12
  worktree, commit changes, fetch/tail application logs, lint a repo, find dead API routes /
13
- unused .sql files, or start working on / take an issue ("work on issue #N"), in a repo that has
13
+ unused .sql files, find out which environment variables exist, or start working on / take an issue ("work on issue #N"), in a repo that has
14
14
  bmsdna-devtools installed (check for `bdt` on PATH, or `bmsdna-devtools` in
15
15
  pyproject.toml, before assuming it applies).
16
16
  ---
@@ -130,6 +130,12 @@ vendor a git submodule under that path.
130
130
  adopt with `--update-baseline`). `--only sql|routes` runs one check. The routes check imports the app, so run it with
131
131
  the repo's own interpreter (`uv run`). Generated API-client code and tests never count as callers.
132
132
 
133
+ ## Environment variables
134
+
135
+ `bdt env get-keys [--search TEXT] [--no-home]` lists the variable **names** (never values) from `.env`, `*.env` and `.env.*`
136
+ files under the current directory (recursive; skips node_modules/.git/.venv/.worktrees/.claude) and in `~` (`--no-home` skips those). Use it instead of `cat .env` / `printenv` to find out which variables exist;
137
+ files in `~` (and ones using `export VAR=...`) are bash syntax, so the output says to `source` them.
138
+
133
139
  ## Application Insights logs
134
140
 
135
141
  ```bash
@@ -0,0 +1,92 @@
1
+ from pathlib import Path
2
+
3
+ from typer.testing import CliRunner
4
+
5
+ from bmsdna.devtools.cli import app
6
+ from bmsdna.devtools.env_files import format_keys, get_keys
7
+
8
+
9
+ def _setup(tmp_path: Path) -> tuple[Path, Path]:
10
+ cwd = tmp_path / "proj"
11
+ home = tmp_path / "home"
12
+ cwd.mkdir()
13
+ home.mkdir()
14
+ (cwd / ".env").write_text("# comment\nDB_URL=postgres://u:secret@h/db\n\nAPI_KEY = 'topsecret'\nDB_URL=dup\n")
15
+ (cwd / "ci.env").write_text("export TOKEN=abc\n")
16
+ (cwd / "notes.txt").write_text("NOT_AN_ENV=1\n")
17
+ (home / "work.env").write_text("export HOME_VAR=hunter2\n")
18
+ return cwd, home
19
+
20
+
21
+ def test_lists_keys_only_never_values(tmp_path):
22
+ cwd, home = _setup(tmp_path)
23
+ out = format_keys(get_keys(cwd, home), home, cwd)
24
+ assert "DB_URL" in out and "API_KEY" in out and "TOKEN" in out and "HOME_VAR" in out
25
+ assert "secret" not in out and "hunter2" not in out and "abc" not in out
26
+ assert "NOT_AN_ENV" not in out
27
+ assert out.count("DB_URL") == 1
28
+
29
+
30
+ def test_bash_syntax_hint_for_home_and_export_files(tmp_path):
31
+ cwd, home = _setup(tmp_path)
32
+ out = format_keys(get_keys(cwd, home), home, cwd)
33
+ assert "source ~/work.env" in out
34
+ assert "source ./ci.env" in out
35
+ assert "source ./.env" not in out
36
+
37
+
38
+ def test_search_filters_keys_and_drops_empty_files(tmp_path):
39
+ cwd, home = _setup(tmp_path)
40
+ files = get_keys(cwd, home, "api")
41
+ assert [f.keys for f in files] == [["API_KEY"]]
42
+
43
+
44
+ def test_cli_exit_1_when_nothing_found(tmp_path, monkeypatch):
45
+ monkeypatch.chdir(tmp_path)
46
+ monkeypatch.setenv("HOME", str(tmp_path))
47
+ res = CliRunner().invoke(app, ["env", "get-keys"])
48
+ assert res.exit_code == 1
49
+
50
+
51
+ def test_cli_prints_keys(tmp_path, monkeypatch):
52
+ cwd, home = _setup(tmp_path)
53
+ monkeypatch.chdir(cwd)
54
+ monkeypatch.setenv("HOME", str(home))
55
+ res = CliRunner().invoke(app, ["env", "get-keys", "--search", "token"])
56
+ assert res.exit_code == 0
57
+ assert "TOKEN" in res.output and "abc" not in res.output
58
+
59
+
60
+ def test_scans_subdirectories_but_skips_excluded_dirs(tmp_path):
61
+ cwd, home = _setup(tmp_path)
62
+ (cwd / "app").mkdir()
63
+ (cwd / "app" / ".env").write_text("SUB_VAR=1\n")
64
+ for d in ("node_modules", ".git", ".venv", ".worktrees", ".claude"):
65
+ (cwd / d / "pkg").mkdir(parents=True)
66
+ (cwd / d / "pkg" / ".env").write_text(f"SKIPPED_{d.strip('.').upper()}=1\n")
67
+ out = format_keys(get_keys(cwd, home), home, cwd)
68
+ assert "app/.env:" in out and "SUB_VAR" in out
69
+ assert "SKIPPED_" not in out
70
+
71
+
72
+ def test_no_home_omits_home_files(tmp_path):
73
+ cwd, home = _setup(tmp_path)
74
+ out = format_keys(get_keys(cwd, None), None, cwd)
75
+ assert "HOME_VAR" not in out and "DB_URL" in out
76
+
77
+
78
+ def test_cwd_equal_home_is_not_walked_recursively(tmp_path):
79
+ cwd, home = _setup(tmp_path)
80
+ (home / "deep").mkdir()
81
+ (home / "deep" / ".env").write_text("DEEP=1\n")
82
+ out = format_keys(get_keys(home, home), home, home)
83
+ assert "HOME_VAR" in out and "DEEP" not in out
84
+
85
+
86
+ def test_cli_no_home_flag(tmp_path, monkeypatch):
87
+ cwd, home = _setup(tmp_path)
88
+ monkeypatch.chdir(cwd)
89
+ monkeypatch.setenv("HOME", str(home))
90
+ res = CliRunner().invoke(app, ["env", "get-keys", "--no-home"])
91
+ assert res.exit_code == 0
92
+ assert "DB_URL" in res.output and "HOME_VAR" not in res.output
@@ -22,7 +22,7 @@ wheels = [
22
22
 
23
23
  [[package]]
24
24
  name = "bmsdna-devtools"
25
- version = "0.31.0"
25
+ version = "0.32.0"
26
26
  source = { editable = "." }
27
27
  dependencies = [
28
28
  { name = "pgdevkit", extra = ["db"] },