agctl 0.2.0__tar.gz → 0.3.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 (182) hide show
  1. agctl-0.3.0/.claude/agents/docs-watcher.md +182 -0
  2. {agctl-0.2.0 → agctl-0.3.0}/.gitignore +3 -0
  3. agctl-0.3.0/.superpowers/sdd/hardening-report.md +208 -0
  4. agctl-0.3.0/.superpowers/sdd/progress.md +50 -0
  5. agctl-0.3.0/.superpowers/sdd/review-062a2b7..6877e76.diff +165 -0
  6. agctl-0.3.0/.superpowers/sdd/review-0ab08be..704335c.diff +192 -0
  7. agctl-0.3.0/.superpowers/sdd/review-61f60a9..062a2b7.diff +830 -0
  8. agctl-0.3.0/.superpowers/sdd/review-6388ab6..80ff01e.diff +103 -0
  9. agctl-0.3.0/.superpowers/sdd/review-6877e76..0ab08be.diff +661 -0
  10. agctl-0.3.0/.superpowers/sdd/review-704335c..fc66690.diff +383 -0
  11. agctl-0.3.0/.superpowers/sdd/review-80ff01e..61f60a9.diff +371 -0
  12. agctl-0.3.0/.superpowers/sdd/review-93570b1..e932d8d.diff +3387 -0
  13. agctl-0.3.0/.superpowers/sdd/task-1-brief.md +34 -0
  14. agctl-0.3.0/.superpowers/sdd/task-1-report.md +63 -0
  15. agctl-0.3.0/.superpowers/sdd/task-2-brief.md +57 -0
  16. agctl-0.3.0/.superpowers/sdd/task-2-report.md +142 -0
  17. agctl-0.3.0/.superpowers/sdd/task-3-brief.md +80 -0
  18. agctl-0.3.0/.superpowers/sdd/task-3-report.md +243 -0
  19. agctl-0.3.0/.superpowers/sdd/task-4-brief.md +44 -0
  20. agctl-0.3.0/.superpowers/sdd/task-4-report.md +79 -0
  21. agctl-0.3.0/.superpowers/sdd/task-5-brief.md +74 -0
  22. agctl-0.3.0/.superpowers/sdd/task-5-report.md +185 -0
  23. agctl-0.3.0/.superpowers/sdd/task-6-brief.md +57 -0
  24. agctl-0.3.0/.superpowers/sdd/task-6-report.md +138 -0
  25. agctl-0.3.0/.superpowers/sdd/task-7-brief.md +44 -0
  26. agctl-0.3.0/.superpowers/sdd/task-7-report.md +169 -0
  27. {agctl-0.2.0 → agctl-0.3.0}/PKG-INFO +5 -2
  28. {agctl-0.2.0 → agctl-0.3.0}/README.md +2 -1
  29. agctl-0.3.0/agctl/assertions.py +298 -0
  30. {agctl-0.2.0 → agctl-0.3.0}/agctl/cli.py +2 -1
  31. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/db_client.py +30 -0
  32. agctl-0.3.0/agctl/clients/db_drivers/postgresql.py +584 -0
  33. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/config_commands.py +5 -0
  34. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/db_commands.py +191 -14
  35. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/http_commands.py +140 -4
  36. {agctl-0.2.0 → agctl-0.3.0}/agctl/config/models.py +14 -1
  37. {agctl-0.2.0 → agctl-0.3.0}/agctl/config/validator.py +26 -0
  38. {agctl-0.2.0 → agctl-0.3.0}/agctl/mock/engine.py +15 -0
  39. {agctl-0.2.0 → agctl-0.3.0}/agctl/mock/http_server.py +8 -1
  40. agctl-0.3.0/agctl/mock/jq_precompile.py +70 -0
  41. {agctl-0.2.0 → agctl-0.3.0}/docs/ARCHITECTURE.md +105 -18
  42. {agctl-0.2.0 → agctl-0.3.0}/docs/DESIGN.md +97 -4
  43. agctl-0.3.0/docs/superpowers/plans/active/2026-07-04-agctl-http-jq.md +484 -0
  44. agctl-0.3.0/docs/superpowers/plans/archive/2026-07-04-db-schema-discovery.md +417 -0
  45. agctl-0.3.0/docs/superpowers/plans/archive/2026-07-04-test-runbook-skills.md +384 -0
  46. agctl-0.3.0/docs/superpowers/specs/active/2026-07-04-agctl-http-jq-design.md +452 -0
  47. agctl-0.3.0/docs/superpowers/specs/archive/2026-07-04-db-schema-discovery-design.md +409 -0
  48. agctl-0.3.0/docs/superpowers/specs/archive/2026-07-04-test-runbook-skills-design.md +232 -0
  49. {agctl-0.2.0 → agctl-0.3.0}/pyproject.toml +2 -1
  50. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl/SKILL.md +120 -3
  51. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/SKILL.md +19 -1
  52. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/reference/db-template.md +2 -2
  53. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/reference/http-template.md +4 -1
  54. agctl-0.3.0/skills/agctl-config/reference/mocks.md +220 -0
  55. agctl-0.3.0/skills/agctl-run-test-runbook/SKILL.md +107 -0
  56. agctl-0.3.0/skills/agctl-run-test-runbook/reference/report-annotation.md +92 -0
  57. agctl-0.3.0/skills/agctl-write-test-runbook/SKILL.md +89 -0
  58. agctl-0.3.0/skills/agctl-write-test-runbook/reference/fixtures-heartbeat.md +37 -0
  59. agctl-0.3.0/skills/agctl-write-test-runbook/reference/fixtures-mock.md +66 -0
  60. agctl-0.3.0/skills/agctl-write-test-runbook/reference/runbook-template.md +65 -0
  61. {agctl-0.2.0 → agctl-0.3.0}/tests/integration/test_db_commands.py +167 -0
  62. agctl-0.3.0/tests/integration/test_http_commands.py +160 -0
  63. {agctl-0.2.0 → agctl-0.3.0}/tests/integration/test_mock_commands.py +150 -0
  64. agctl-0.3.0/tests/unit/test_assertions.py +579 -0
  65. agctl-0.3.0/tests/unit/test_config_commands.py +138 -0
  66. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_db_client.py +81 -0
  67. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_db_commands.py +336 -5
  68. agctl-0.3.0/tests/unit/test_http_commands.py +633 -0
  69. agctl-0.3.0/tests/unit/test_jq_precompile.py +284 -0
  70. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_engine.py +206 -1
  71. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_http_server.py +243 -0
  72. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_models.py +21 -0
  73. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_models.py +13 -0
  74. agctl-0.3.0/tests/unit/test_packaging.py +22 -0
  75. agctl-0.3.0/tests/unit/test_postgresql_driver.py +1154 -0
  76. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_resolver.py +10 -0
  77. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_validator.py +136 -0
  78. agctl-0.2.0/.claude/agents/docs-watcher.md +0 -94
  79. agctl-0.2.0/.superpowers/sdd/progress.md +0 -30
  80. agctl-0.2.0/.superpowers/sdd/task-1-brief.md +0 -53
  81. agctl-0.2.0/.superpowers/sdd/task-1-report.md +0 -187
  82. agctl-0.2.0/.superpowers/sdd/task-2-brief.md +0 -43
  83. agctl-0.2.0/.superpowers/sdd/task-2-report.md +0 -76
  84. agctl-0.2.0/.superpowers/sdd/task-3-brief.md +0 -46
  85. agctl-0.2.0/.superpowers/sdd/task-3-report.md +0 -118
  86. agctl-0.2.0/.superpowers/sdd/task-4-brief.md +0 -51
  87. agctl-0.2.0/.superpowers/sdd/task-4-report.md +0 -166
  88. agctl-0.2.0/.superpowers/sdd/task-5-brief.md +0 -52
  89. agctl-0.2.0/.superpowers/sdd/task-5-report.md +0 -389
  90. agctl-0.2.0/.superpowers/sdd/task-6-brief.md +0 -50
  91. agctl-0.2.0/.superpowers/sdd/task-6-report.md +0 -151
  92. agctl-0.2.0/.superpowers/sdd/task-7-brief.md +0 -51
  93. agctl-0.2.0/.superpowers/sdd/task-7-report.md +0 -486
  94. agctl-0.2.0/agctl/assertions.py +0 -135
  95. agctl-0.2.0/agctl/clients/db_drivers/postgresql.py +0 -142
  96. agctl-0.2.0/tests/integration/test_http_commands.py +0 -75
  97. agctl-0.2.0/tests/unit/test_assertions.py +0 -205
  98. agctl-0.2.0/tests/unit/test_http_commands.py +0 -257
  99. agctl-0.2.0/tests/unit/test_postgresql_driver.py +0 -352
  100. {agctl-0.2.0 → agctl-0.3.0}/.claude/skills/pypi-publish/SKILL.md +0 -0
  101. {agctl-0.2.0 → agctl-0.3.0}/.claude/skills/pypi-publish/reference.md +0 -0
  102. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/.gitignore +0 -0
  103. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-04c89be..994ff5e.diff +0 -0
  104. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-04c89be..e125cce.diff +0 -0
  105. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-5488ac3..f617e4d.diff +0 -0
  106. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-5524aaa..47790df.diff +0 -0
  107. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-6e0bc77..4cadfb6.diff +0 -0
  108. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-6e0bc77..5524aaa.diff +0 -0
  109. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-70bad42..d99cbdc.diff +0 -0
  110. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-91271c7..b5bb878.diff +0 -0
  111. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-9fabeda..5488ac3.diff +0 -0
  112. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-b5bb878..1ec55c1.diff +0 -0
  113. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-b5bb878..6e0bc77.diff +0 -0
  114. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-e125cce..1fd899f.diff +0 -0
  115. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-e125cce..70bad42.diff +0 -0
  116. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-e942e2d..eff0aef.diff +0 -0
  117. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/review-eff0aef..91271c7.diff +0 -0
  118. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/task-8-brief.md +0 -0
  119. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/task-8-report.md +0 -0
  120. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/task-9-brief.md +0 -0
  121. {agctl-0.2.0 → agctl-0.3.0}/.superpowers/sdd/task-9-report.md +0 -0
  122. {agctl-0.2.0 → agctl-0.3.0}/CLAUDE.md +0 -0
  123. {agctl-0.2.0 → agctl-0.3.0}/LICENSE +0 -0
  124. {agctl-0.2.0 → agctl-0.3.0}/agctl/__init__.py +0 -0
  125. {agctl-0.2.0 → agctl-0.3.0}/agctl/assertion_registry.py +0 -0
  126. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/__init__.py +0 -0
  127. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/db_driver_protocol.py +0 -0
  128. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/db_drivers/__init__.py +0 -0
  129. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/http_client.py +0 -0
  130. {agctl-0.2.0 → agctl-0.3.0}/agctl/clients/kafka_client.py +0 -0
  131. {agctl-0.2.0 → agctl-0.3.0}/agctl/command.py +0 -0
  132. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/__init__.py +0 -0
  133. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/check_commands.py +0 -0
  134. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/discover_commands.py +0 -0
  135. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/kafka_commands.py +0 -0
  136. {agctl-0.2.0 → agctl-0.3.0}/agctl/commands/mock_commands.py +0 -0
  137. {agctl-0.2.0 → agctl-0.3.0}/agctl/config/__init__.py +0 -0
  138. {agctl-0.2.0 → agctl-0.3.0}/agctl/config/loader.py +0 -0
  139. {agctl-0.2.0 → agctl-0.3.0}/agctl/config/resolver.py +0 -0
  140. {agctl-0.2.0 → agctl-0.3.0}/agctl/data/sample-config.yaml +0 -0
  141. {agctl-0.2.0 → agctl-0.3.0}/agctl/errors.py +0 -0
  142. {agctl-0.2.0 → agctl-0.3.0}/agctl/mock/__init__.py +0 -0
  143. {agctl-0.2.0 → agctl-0.3.0}/agctl/mock/kafka_reactor.py +0 -0
  144. {agctl-0.2.0 → agctl-0.3.0}/agctl/mock/routing.py +0 -0
  145. {agctl-0.2.0 → agctl-0.3.0}/agctl/output.py +0 -0
  146. {agctl-0.2.0 → agctl-0.3.0}/agctl/params.py +0 -0
  147. {agctl-0.2.0 → agctl-0.3.0}/agctl/plugin_protocol.py +0 -0
  148. {agctl-0.2.0 → agctl-0.3.0}/agctl/resolution.py +0 -0
  149. {agctl-0.2.0 → agctl-0.3.0}/docs/superpowers/plans/archive/2026-07-03-db-write-execute.md +0 -0
  150. {agctl-0.2.0 → agctl-0.3.0}/docs/superpowers/plans/archive/2026-07-03-mock-server.md +0 -0
  151. {agctl-0.2.0 → agctl-0.3.0}/docs/superpowers/specs/archive/2026-07-03-agctl-mock-server-design.md +0 -0
  152. {agctl-0.2.0 → agctl-0.3.0}/docs/superpowers/specs/archive/2026-07-03-db-write-execute-design.md +0 -0
  153. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/reference/db-write-template.md +0 -0
  154. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/reference/init-config.md +0 -0
  155. {agctl-0.2.0 → agctl-0.3.0}/skills/agctl-config/reference/kafka-pattern.md +0 -0
  156. {agctl-0.2.0 → agctl-0.3.0}/tests/__init__.py +0 -0
  157. {agctl-0.2.0 → agctl-0.3.0}/tests/fixtures/agctl.yaml +0 -0
  158. {agctl-0.2.0 → agctl-0.3.0}/tests/integration/__init__.py +0 -0
  159. {agctl-0.2.0 → agctl-0.3.0}/tests/integration/conftest.py +0 -0
  160. {agctl-0.2.0 → agctl-0.3.0}/tests/integration/test_kafka_commands.py +0 -0
  161. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/__init__.py +0 -0
  162. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_assertion_registry.py +0 -0
  163. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_check_commands.py +0 -0
  164. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_cli.py +0 -0
  165. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_command.py +0 -0
  166. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_discover_command.py +0 -0
  167. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_discovery.py +0 -0
  168. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_errors.py +0 -0
  169. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_http_client.py +0 -0
  170. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_http_ping.py +0 -0
  171. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_interpolation.py +0 -0
  172. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_kafka_client.py +0 -0
  173. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_kafka_commands.py +0 -0
  174. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_loader.py +0 -0
  175. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_commands.py +0 -0
  176. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_kafka_reactor.py +0 -0
  177. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_mock_routing.py +0 -0
  178. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_output.py +0 -0
  179. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_params.py +0 -0
  180. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_plugins.py +0 -0
  181. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_resolution.py +0 -0
  182. {agctl-0.2.0 → agctl-0.3.0}/tests/unit/test_smoke.py +0 -0
@@ -0,0 +1,182 @@
1
+ ---
2
+ name: docs-watcher
3
+ description: Review code/config changes and keep all user-facing docs fresh — DESIGN.md (WHAT/WHY), ARCHITECTURE.md (HOW), and the consumer skills/ tree (operational CLI/config reference an agent follows).
4
+ tools: Read, Grep, Glob, Edit, Write, Bash
5
+ model: sonnet
6
+ ---
7
+
8
+ # docs-watcher Subagent
9
+
10
+ You are the `docs-watcher` subagent for the `agctl` project. Your job is to review code and
11
+ configuration changes, then decide which **user-facing markdown** needs updating to stay
12
+ fresh against the as-built code.
13
+
14
+ You own **three document families**, each at a different abstraction altitude. Preserving
15
+ the altitude of each — and catching the case where code shipped but a family was forgotten —
16
+ is your core responsibility. The mock-server feature shipped with DESIGN.md and
17
+ ARCHITECTURE.md synced but the `skills/` tree stale; that gap is exactly what you exist to
18
+ close.
19
+
20
+ ## The Documents and Their Altitudes
21
+
22
+ ### `docs/DESIGN.md`
23
+ **Altitude:** WHAT and WHY — design-level, user-facing contract.
24
+
25
+ Contains: goals/non-goals (§1); config schema — fields and meaning (§2); CLI command surface
26
+ — flags, args, behavior (§3); output schema — JSON structure, error types (§4); config
27
+ resolution order (§5); extension contracts (§9); roadmap/future work (§10).
28
+
29
+ **What does NOT belong here:** implementation mechanics, module layouts, internal data flows.
30
+
31
+ ### `docs/ARCHITECTURE.md`
32
+ **Altitude:** HOW — implementation-level, as-built source of truth.
33
+
34
+ Contains: module & layer map (§3); request lifecycle (§4); config pipeline (§5); transport/
35
+ client internals incl. lazy imports and exception mappings (§8); testing architecture (§12);
36
+ design-vs-implementation deltas (§14).
37
+
38
+ **What does NOT belong here:** user-facing behavior changes that are spec-level, not
39
+ implementation-level.
40
+
41
+ ### `skills/` (consumer skills — agents copy these into their own repos)
42
+ **Altitude:** OPERATIONAL — "what an agent needs to know to use agctl correctly **today**."
43
+
44
+ - **`skills/agctl/SKILL.md`** — *driving* the CLI: the command surface, flags, the one-JSON-
45
+ object-per-invocation contract (and its streaming exceptions), exit-code meanings, output
46
+ parsing, gotchas, command forms, recipes, and lifecycle protocols. **Stale here = an agent
47
+ issues a wrong command, misreads output, or misses a gotcha.**
48
+ - **`skills/agctl-config/SKILL.md` + `reference/*.md`** — *authoring* `agctl.yaml`: the config
49
+ contract (placeholder syntaxes, cross-refs, naming, verify-after), the mode table, the
50
+ structural checklist, and one `reference/<mode>.md` per section (http / kafka / db / db-write
51
+ / mock / init). **Stale here = an agent writes invalid config or misses a schema/validation
52
+ rule.**
53
+
54
+ **What does NOT belong here:** design rationale (that's DESIGN) or internal mechanics
55
+ (that's ARCHITECTURE). Skills state the *operational surface* — what to type, what comes
56
+ back, what to watch for.
57
+
58
+ ## Mapping a Change to Documents
59
+
60
+ A single change can land in several families. Update **every** family that applies, each at
61
+ its own altitude:
62
+
63
+ | Change | DESIGN.md | ARCHITECTURE.md | skills/agctl | skills/agctl-config |
64
+ |---|---|---|---|---|
65
+ | New/changed CLI command, flag, output shape, exit code, runtime behavior | §3 / §4 | §4 / §6 / §8 (if internal flow changes) | intent table, command forms, gotchas, recipes | — |
66
+ | New/changed config field, validation rule, placeholder semantics | §2 | §5 (pipeline) / §15 (limitations) | gotchas (if user-facing) | SKILL.md contract + structural checklist + matching `reference/<mode>.md` |
67
+ | New/changed `discover` category or item shape | §3 | — | discover section + category list | verify/discover notes |
68
+ | Internal module layout, runtime flow, packaging, test seams | (only if user-visible) | §3 / §4 / §8 / §12 / §14 | — | — |
69
+ | Pure refactor / test-only / cosmetic, no behavior change | — | — | — | — |
70
+
71
+ **Overlaps are the rule, not the exception.** A new command usually touches DESIGN §3 **and**
72
+ `skills/agctl`; a new config field usually touches DESIGN §2 **and** `skills/agctl-config`.
73
+ The mock feature touched all four. When in doubt, check each family.
74
+
75
+ ## Your Decision Process
76
+
77
+ For every code/config change, you MUST:
78
+
79
+ 1. **Read what changed** — `git status` and `git diff` (against the appropriate base) to see
80
+ what materially changed in behavior or structure.
81
+
82
+ 2. **Classify the change:**
83
+ - **(a) User-facing behavior/contract change** — new/changed CLI flags, config schema
84
+ fields, output schema, error types, extension contracts, discover surface.
85
+ - **(b) Internal structural/architectural change** — module layout, runtime flow, internal
86
+ mechanisms, packaging, testing architecture.
87
+ - **(c) Trivial/cosmetic/refactor-with-no-behavior-change** — test additions, formatting,
88
+ behavior-preserving refactors.
89
+
90
+ 3. **For each document family, ask:** does this change fall within this family's SCOPE **and**
91
+ ALTITUDE, **and** does it make the family's *current text* stale?
92
+ - DESIGN.md: user-facing contract changes (type a).
93
+ - ARCHITECTURE.md: internal structural changes (type b).
94
+ - skills/: the operational surface an agent relies on — usually the user-facing slice of
95
+ (a), occasionally the user-visible consequence of (b).
96
+
97
+ 4. **Decide:**
98
+ - If the change belongs in a family AT ITS ALTITUDE and is IMPORTANT → update it, matching
99
+ the file's existing style, terseness, and structure exactly. Edit only the relevant
100
+ lines/rows/bullets; do not expand or restructure the section.
101
+ - If the change has no home at this granularity, is trivial, or sits below the family's
102
+ altitude → **DO NOT update. A correct no-op is better than a speculative edit.**
103
+
104
+ 5. **Default to leaving docs untouched.** When unsure, do not edit — and say so.
105
+
106
+ ## Skills Freshness — Specific Rules
107
+
108
+ These apply *in addition* to the general rules below:
109
+
110
+ 1. **Reflect AS-BUILT reality, never aspirational specs.** If a design spec said "discover
111
+ will surface X" but the code deferred it, the skill must say X is **not** surfaced. A skill
112
+ that claims a feature works when the code deferred it is a silent false green — the worst
113
+ failure mode for a test tool's docs.
114
+
115
+ 2. **State deferrals/limitations where an agent would otherwise assume support.** A behavior
116
+ the MVP doesn't cover must appear in the skill (gotcha, "not covered" note, or a
117
+ pointed-out absence) — not just in DESIGN §10. If `agctl discover` has no `mocks`
118
+ category, the skill says so.
119
+
120
+ 3. **Edit surgically and preserve structure.** Each skill file has a fixed shape — the mode
121
+ table, the numbered gotchas, the command-forms block, the recipes, the structural
122
+ checklist. Add a row / line / bullet / checklist item in the right slot; do not renumber,
123
+ reorder, or rewrite.
124
+
125
+ 4. **Keep cross-references intact.** The two skills point at each other (`agctl` ↔
126
+ `agctl-config`) and at `reference/<mode>.md` files. When you add a mode or command, wire
127
+ the cross-refs on both sides.
128
+
129
+ 5. **Watch for facts repeated across files.** Exit-code meanings, the placeholder-syntax
130
+ table, the `discover` category list, and "streaming commands" appear in more than one
131
+ place. If one copy changes, check the others. (When `mock run` became the second streaming
132
+ command, "http ping is the only streaming command" became wrong in `skills/agctl`.)
133
+
134
+ 6. **Skills are consumer artifacts, not repo internals.** They are copied verbatim into other
135
+ repos. Don't reference repo-internal paths, build commands, or test files from inside a
136
+ skill — only the `agctl` CLI surface and `agctl.yaml`.
137
+
138
+ ## Your Rules
139
+
140
+ 1. **NEVER change a document's altitude.** No implementation detail in DESIGN.md; no
141
+ operational how-to in ARCHITECTURE.md; no design rationale or internal mechanics in skills/.
142
+
143
+ 2. **NEVER invent new sections.** If a change has no natural home in an existing section, it
144
+ does not belong in that document.
145
+
146
+ 3. **Match existing style exactly.** Preserve each file's voice, terseness, table format, and
147
+ level of detail. Do not expand a section just because you can.
148
+
149
+ 4. **Reflect the code, not the spec.** Specs under `docs/superpowers/specs/` are historical
150
+ design records — never edit them, and never let them override what the code actually does.
151
+
152
+ 5. **Report transparently.** ALWAYS end by reporting:
153
+ - What you reviewed.
154
+ - What you changed (one-line reason per change, per family).
155
+ - What you deliberately did NOT change (and why) — including any family you checked and
156
+ found already fresh.
157
+
158
+ 6. **Git is your source of truth.** Use `git diff` to see what actually changed. Do not
159
+ speculate from file names alone.
160
+
161
+ ## Example Workflow
162
+
163
+ 1. `git status` → see which files changed.
164
+ 2. `git diff <base> -- <files>` → read the actual changes.
165
+ 3. Classify each change (a / b / c).
166
+ 4. For each family (DESIGN / ARCHITECTURE / skills-agctl / skills-agctl-config), ask the
167
+ scope+altitude+staleness question. Consult the mapping table above.
168
+ 5. When a skill is in scope, read the relevant skill file to see whether its current text is
169
+ now stale (don't assume — verify the claim against the code before editing).
170
+ 6. Make edits ONLY when the answer is "yes, at this altitude, important, and currently stale."
171
+ 7. Report your findings across all families.
172
+
173
+ ## What You Do NOT Do
174
+
175
+ - Do NOT update docs for test additions or test-only changes.
176
+ - Do NOT update docs for cosmetic refactorings (renames, formatting) that preserve behavior.
177
+ - Do NOT update docs for internal helpers that aren't user-visible.
178
+ - Do NOT "cover" a change by inventing a new section.
179
+ - Do NOT touch archived specs under `docs/superpowers/specs/` — they are frozen history.
180
+ - Do NOT sync the packaged `agctl/data/sample-config.yaml` to the README — that drift is
181
+ enforced by a test, not by you.
182
+ - Do NOT silently edit — always report what you did and why, per family.
@@ -23,3 +23,6 @@ venv/
23
23
 
24
24
  # UV package manager
25
25
  uv.lock
26
+
27
+ # agctl test-runbook results (ephemeral per-run reports)
28
+ *.results.md
@@ -0,0 +1,208 @@
1
+ # Security Hardening: Redact Secrets from `ConnectionFailure` Detail in `connect()`
2
+
3
+ ## Summary
4
+
5
+ Fixed a secret-leak finding from code review: `PostgreSQLDriver.connect()`
6
+ emitted the raw connection config (including `password` and any URL-embedded
7
+ credentials) into `ConnectionFailure.detail`, which `@envelope` writes to
8
+ stdout — exposing secrets to the agent and any captured transcript.
9
+
10
+ The fix adds a module-level `_redact_config` helper and applies it to the config
11
+ copy that enters the error detail. The original config (used for the actual
12
+ `psycopg.connect` call) is untouched.
13
+
14
+ ## The Leak (Before)
15
+
16
+ `agctl/clients/db_drivers/postgresql.py` `connect()` raised:
17
+
18
+ ```python
19
+ raise ConnectionFailure(
20
+ message=str(exc),
21
+ detail={"driver": "postgresql", "config": dict(config)},
22
+ ) from exc
23
+ ```
24
+
25
+ `config` is the raw connection dict (`type/host/port/dbname/user/password/url`).
26
+ `AgctlError.to_dict()` emits `detail` verbatim into the envelope's
27
+ `error.detail`, which `@envelope` writes to stdout. So an auth/connection
28
+ failure printed `error.detail.config.password` and any `user:pass@` segment
29
+ inside `config.url` to stdout.
30
+
31
+ The other `ConnectionFailure` raises in this file (`execute`/`execute_write`)
32
+ pass only `message=str(exc)` with no `detail` — they do NOT leak. Only
33
+ `connect()` needed the fix. (`describe_schema` does not exist on this driver.)
34
+
35
+ ## The Fix (After)
36
+
37
+ Added `_redact_config(config: dict) -> dict` at module scope in
38
+ `agctl/clients/db_drivers/postgresql.py`. It returns a **copy** with:
39
+
40
+ 1. **Secret-style keys** — any key whose name matches the substring pattern
41
+ `password|secret|token|key` (case-insensitive) — replaced by the fixed
42
+ sentinel `"***"`. This covers `password`, `key_password`, `api_token`,
43
+ `secret`, etc.
44
+ 2. **URL userinfo** — a `url` string value with a leading `user:pass@` (or
45
+ `user@`) userinfo prefix has that prefix replaced by `"***"`:
46
+ `postgres://u:p4ss@h:5432/db` -> `postgres://***@h:5432/db`. Handles both
47
+ `postgres://` and `postgresql://` schemes. URLs without userinfo, and
48
+ non-string `url` values, pass through unchanged.
49
+
50
+ The original `config` is NOT mutated (the caller still needs the real values
51
+ for the actual connection attempt / retry).
52
+
53
+ Wired into `connect()`:
54
+
55
+ ```python
56
+ detail={"driver": "postgresql", "config": _redact_config(config)},
57
+ ```
58
+
59
+ Only `connect()` was touched. `execute` / `execute_write` were left as-is (they
60
+ don't leak).
61
+
62
+ ## Helper's Home + Rationale
63
+
64
+ **Location:** module-level function `_redact_config` in
65
+ `agctl/clients/db_drivers/postgresql.py` (near its only caller).
66
+
67
+ **Why not a shared util (`agctl/utils.py` / `agctl/_redact.py`)?**
68
+ - No shared redaction util currently exists in the codebase (checked
69
+ `agctl/utils*`, `agctl/_*`, and grepped for `redact|sanitize|scrub` — no
70
+ hits).
71
+ - The helper's only caller is this driver's `connect()`. Co-locating it keeps
72
+ the diff minimal and the logic next to the call site that needs it.
73
+ - The Kafka leak check (below) found no other call site that puts secrets into
74
+ an error `detail`, so there is no second consumer to justify extraction.
75
+
76
+ If a second leak surface appears later (e.g. a future driver copies this
77
+ pattern), the helper can be promoted to a shared util at that point — the
78
+ function signature (`dict -> dict`) is already general.
79
+
80
+ ## TDD Evidence
81
+
82
+ ### RED (failing test, before the fix)
83
+
84
+ Command:
85
+ ```
86
+ python3 -m pytest tests/unit/test_postgresql_driver.py::test_connect_failure_redacts_secrets_from_detail -q
87
+ ```
88
+
89
+ Output (tail):
90
+ ```
91
+ > assert redacted_config["password"] == "***"
92
+ E AssertionError: assert 's3cret' == '***'
93
+ E
94
+ E - ***
95
+ E + s3cret
96
+ tests/unit/test_postgresql_driver.py:312: AssertionError
97
+ =========================== short test summary info ============================
98
+ FAILED tests/unit/test_postgresql_driver.py::test_connect_failure_redacts_secrets_from_detail
99
+ 1 failed in 0.08s
100
+ ```
101
+
102
+ ### GREEN (after the fix)
103
+
104
+ New test in isolation:
105
+ ```
106
+ python3 -m pytest tests/unit/test_postgresql_driver.py::test_connect_failure_redacts_secrets_from_detail -q
107
+ . [100%]
108
+ 1 passed in 0.03s
109
+ ```
110
+
111
+ Full driver file (confirms existing connection-failure tests still pass):
112
+ ```
113
+ python3 -m pytest tests/unit/test_postgresql_driver.py -q
114
+ ......................... [100%]
115
+ 25 passed in 0.04s
116
+ ```
117
+
118
+ Full unit suite (no regressions):
119
+ ```
120
+ python3 -m pytest tests/unit -q
121
+ ........................................................................ [ 14%]
122
+ ........................................................................ [ 28%]
123
+ ........................................................................ [ 42%]
124
+ ........................................................................ [ 57%]
125
+ ........................................................................ [ 71%]
126
+ ........................................................................ [ 85%]
127
+ ........................................................................ [100%]
128
+ 504 passed in 9.71s
129
+ ```
130
+
131
+ Output is pristine (no warnings, no skips, no errors).
132
+
133
+ The new test asserts:
134
+ - `detail["config"]["password"] == "***"` (literal redaction).
135
+ - `"s3cret" not in json.dumps(detail)` AND `"p4ss" not in json.dumps(detail)`
136
+ (strongest guarantee — covers url-embedded creds and any other secret-style
137
+ key).
138
+ - `url` userinfo scrubbed (`"p4ss"` and `"u:p4ss@"` absent; `"host:5432"`
139
+ survives).
140
+ - Non-secret keys (`type/host/port/dbname/user`) preserved unchanged.
141
+ - Original caller config untouched (`config["password"] == "s3cret"` and
142
+ `config["url"]` still raw after the call — redaction operates on a copy).
143
+
144
+ ## Files Changed
145
+
146
+ - `agctl/clients/db_drivers/postgresql.py`
147
+ - Added `import re`.
148
+ - Added module-level constants `_SECRET_KEY_PATTERN`, `_URL_USERINFO_PATTERN`,
149
+ `_REDACTED_SENTINEL`.
150
+ - Added module-level function `_redact_config(config: dict) -> dict`.
151
+ - Changed the `connect()` `ConnectionFailure` detail from
152
+ `dict(config)` to `_redact_config(config)`.
153
+ - `tests/unit/test_postgresql_driver.py`
154
+ - Added `test_connect_failure_redacts_secrets_from_detail`.
155
+
156
+ Diffstat: `2 files changed, 98 insertions(+), 1 deletion(-)`.
157
+
158
+ ## Kafka / Other-Driver Leak Check
159
+
160
+ Scanned all `detail={...}` literals across `agctl/` and every
161
+ `ConnectionFailure` raise in the Kafka client.
162
+
163
+ **Result: none found.**
164
+
165
+ - `agctl/clients/kafka_client.py` raises `ConnectionFailure(message=...)` with
166
+ NO `detail` dict at all (lines 154, 156, 160, 166-168, 399, 442-444, 447-449,
167
+ 492, 509, 520, 527, 540). The only data embedded is broker addresses inside
168
+ the `message` string (e.g. line 443: `f"Kafka broker(s) {','.join(self._brokers)} unreachable: {exc}"`),
169
+ which are not secrets. `KafkaSSL.key_password` lives on the parsed config
170
+ object but is never placed into any error `detail`.
171
+ - The only other `detail={...}` literals in the codebase
172
+ (`agctl/config/resolver.py:94` -> `{"field": last}` and
173
+ `agctl/commands/config_commands.py:204` -> `{"resource": ...}`) carry no
174
+ secrets.
175
+
176
+ No follow-up items.
177
+
178
+ ## Self-Review
179
+
180
+ - **COPY, not mutation:** `_redact_config` builds and returns a fresh dict; the
181
+ test explicitly asserts the caller's `config` is unchanged after the call.
182
+ The original `config` is still used verbatim for the `psycopg.connect` kwargs.
183
+ - **Strongest-guarantee assertion holds for both secret carriers:** the test
184
+ asserts `"s3cret" not in json.dumps(detail)` (password) AND
185
+ `"p4ss" not in json.dumps(detail)` (url-embedded creds). Both hold.
186
+ - **Non-secret keys preserved:** asserted for `type/host/port/dbname/user`.
187
+ - **Existing connection-failure tests still pass:** the full driver file
188
+ (25 tests, including `test_connect_raises_connection_failure_on_operational_error`
189
+ and `test_connect_with_url_raises_connection_failure_on_psycopg_error`) is
190
+ green; the full unit suite (504 tests) is green.
191
+ - **`execute`/`execute_write` untouched:** confirmed via the diff — the only
192
+ behavioral change is the one `detail=` line in `connect()`.
193
+
194
+ ## Concerns
195
+
196
+ None.
197
+
198
+ - The URL-userinfo regex (`^(postgres(?:ql)?://)[^@/]+@`) requires an `@`
199
+ before the first `/` to match, so URLs without userinfo pass through
200
+ unchanged. It handles `postgres://`, `postgresql://`, `user:pass@`, and
201
+ `user@` (no password) forms. A pathologically malformed URL with an
202
+ unencoded `@` inside the password (e.g. `postgres://u:p@ss@h/db`) would
203
+ under-redact the password portion — but such URLs are invalid (the `@`
204
+ must be percent-encoded) and not a realistic concern for this fix's scope.
205
+ - The secret-key substring pattern matches `key`, which would also match a
206
+ hypothetical benign key like `keyboard_shortcut` — but no such key exists in
207
+ `DatabaseConnection`'s schema, and the broad match is intentional (the task
208
+ spec asks for substring match on `password|secret|token|key`).
@@ -0,0 +1,50 @@
1
+ # SDD Progress Ledger — db-schema-discovery
2
+
3
+ Branch: feat/db-schema-discovery | Plan: docs/superpowers/plans/active/2026-07-04-db-schema-discovery.md
4
+
5
+ Task 1: complete (commits 6388ab6..80ff01e, review clean — ✅ spec, Approved)
6
+ Task 2: complete (commits 80ff01e..61f60a9, review clean — ✅ spec, Approved)
7
+ Minor (deferred to final review): push D6 system-schema/partition-leaf/relkind exclusions into SQL WHERE for the schema=None case (currently Python-only filter; correct + integration-tested, but pulls extra rows in production). Cosmetic: typo in task-2-report.md RED evidence path.
8
+ Note: Task 2 implementer self-invoked docs-watcher per CLAUDE.md (correct no-op, method unwired); not part of plan Task 2 but harmless.
9
+ Task 3: complete (commits 61f60a9..062a2b7, review clean — ✅ spec, Approved; opus review verified all load-bearing rules in code)
10
+ Minor (deferred to final review):
11
+ M1: silent attnum-drop in constraint resolution (postgresql.py:287-303,321-325) could misalign positional pairing in inconsistent catalog — can't happen in practice (dropping a column drops its constraints); loud failure safer than quiet truncation.
12
+ M2: no ORDER BY on pg_constraint query (FK/unique order deterministic-by-chance; add ORDER BY con.oid for stable output).
13
+ M3: redundant Python schema re-check at postgresql.py:99-100 (dead defensive code).
14
+ M4: Any import verified present (not a defect).
15
+ M5 (WATCH-ITEM for Task 6): conkey/confkey consumed as Python lists; if psycopg3 returns int2vector as a string, a parser is needed. Integration test will surface it.
16
+ Task 4: complete (commits 062a2b7..6877e76, review clean — ✅ spec, Approved; zero findings)
17
+ Task 5: complete (commits 6877e76..0ab08be, review clean — ✅ spec, Approved; opus review verified probe-before-connect structurally guaranteed + disambiguation + verbatim hints)
18
+ Minor (deferred to final review):
19
+ M1: db_commands.py:202,214-222 use .get() on match fields (m.get("schema")/match.get("comment")) where brief specifies subscript (m["schema"]). Functionally equivalent for well-formed driver output; subscript would fail-loudly (KeyError) on a malformed driver contract instead of silently emitting null. Style/robustness, not load-bearing.
20
+ Task 6: complete (commits 0ab08be..704335c, review clean — ✅ spec, Approved; live run PASS against Postgres 16 testcontainer)
21
+ M5 RETIRED: int2vector watch-item confirmed non-issue — psycopg3 returns conkey/confkey as Python int lists; live FK pairing assertions passed.
22
+ Minor (deferred to final review): redundant in-function `import json` at tests/integration/test_db_commands.py:60 (module already imports json).
23
+ Task 7: complete (commits 704335c..e932d8d via fix, review Approved after fix — ✅ spec)
24
+ Fixed in-loop: I1 (DESIGN.md primary_key rendered as {name,columns} object but code returns list[str]; fixed to ["id"]; SKILL.md:142 preventive clarification primary_key→primary_key:[col]). Controller-verified the fix directly (surgical, reviewer-specified, sibling scan clean) — no full re-review subagent.
25
+ Minor (deferred to final review): ARCHITECTURE.md:100-102 lifecycle phrasing "close runs on raises" slightly imprecise (close runs before the raises, in _probe_and_describe's finally) — accurate, leave.
26
+
27
+ ALL TASKS DONE. Branch tip = e932d8d.
28
+ DEFERRED MINOR FINDINGS FOR FINAL REVIEW TRIAGE:
29
+ - T2/M1: push D6 system-schema/partition-leaf/relkind exclusions into SQL WHERE for schema=None (currently Python-only; correct + integration-tested, but pulls extra rows in production).
30
+ - T2/M(report): cosmetic typo in task-2-report.md RED evidence path.
31
+ - T3/M1: silent attnum-drop in constraint resolution (postgresql.py:287-303,321-325) could misalign positional pairing in inconsistent catalog — can't happen in practice; loud failure safer.
32
+ - T3/M2: no ORDER BY on pg_constraint query (FK/unique order deterministic-by-chance; add ORDER BY con.oid for stable output).
33
+ - T3/M3: redundant Python schema re-check at postgresql.py:99-100 (dead defensive code).
34
+ - T5/M1: db_commands.py:202,214-222 use .get() on match fields where brief specifies subscript; subscript would fail-loudly on malformed driver contract.
35
+ - T6/M1: redundant in-function `import json` at tests/integration/test_db_commands.py:60.
36
+ - T7/M1: ARCHITECTURE.md:100-102 lifecycle phrasing (imprecise, accurate).
37
+
38
+ FINAL REVIEW: complete (93570b1..e932d8d). Verdict: Ready to merge — YES. Zero Critical/Important.
39
+ T3/M3 reclassified: NOT a trivial pre-merge cleanup. Fix subagent BLOCKED correctly — the Python schema re-check is load-bearing for the SQL-agnostic CatalogFakeCursor unit tests (scenarios 5/6 assert filtered results via it). Removing it needs a test-fixture refactor (fake honors n.nspname, or stage matching rows) → belongs in the follow-up cluster, not pre-merge. No commit made; tree clean.
40
+ Dropped (false premises): T6/M1 (file has no top-level import json — in-function is house style), T7/M1 (ARCH phrasing accurate).
41
+ FOLLOW-UP ISSUE (one cluster, touch postgresql.py once): T2/M1 push D6 filters into SQL WHERE (efficiency/defense-in-depth for schema=None on busy clusters) + T3/M3 delete dead Python re-check WITH CatalogFakeCursor fixture update + T3/M2 ORDER BY con.conname + T3/M1 loud attnum failure. Plus optional: T5/M1 .get()->subscript; multi-col FK in integration test.
42
+ Branch feat/db-schema-discovery tip = e932d8d. Merge-ready.
43
+
44
+ 4-REVIEWER PARALLEL CODE REVIEW (security / correctness / architecture / tests, all opus):
45
+ Security: YES. 1 Important (PRE-EXISTING, not in diff): connect() ConnectionFailure detail={"config": dict(config)} leaks password to stdout via envelope (postgresql.py:57-61). Reachable today via db query/assert/execute; db schema's ungated nature broadens surface. → recommend SEPARATE hardening PR (redact password/secret*/token*/*key in detail.config). New code clean (perfect bind-param discipline, no identifier construction, no new secrets, lifecycle correct).
46
+ Correctness: YES (empirically verified live vs Postgres 16, incl. composite FK positional pairing + identity-vs-serial redaction). 0 Critical/Important. 2 future-proofing notes: int2vector caveat (conkey is int2[] OID 1005 → list, correct; pg_index.indkey would be a string), pg_description query missing AND classoid='pg_class'::regclass (safe via global OID alloc).
47
+ Architecture: YES. 0 Critical/Important. 5 Minor nits: duplicated capability-refusal error construction (getattr vs _conn_dict['type']); double-probe on happy path (defense-in-depth); Python-side catalog filtering (T2/M1); probe asymmetry vs execute_write (intentional, document why); _probe_and_describe close-timing docstring (T7/M1).
48
+ Tests: WITH FIXES. 1 Important: schema-filter unit-test fidelity gap — CatalogFakeCursor is SQL-agnostic so unit tests can't catch a broken SQL WHERE; Python re-check masks it; integration test backstops only when Postgres available (self-skipping). SAME root as T3/M3 → one coherent fix (fake honors bind params + remove dead Python re-check). 6 Minor coverage-strength suggestions.
49
+
50
+ AGGREGATE: 0 Critical across all 4. 2 Important (both pre-existing/follow-up, neither blocks this branch): password leak (separate PR), schema-filter fidelity (follow-up cluster already documented).
@@ -0,0 +1,165 @@
1
+ # Review package: 062a2b7..6877e76
2
+
3
+ ## Commits
4
+ 6877e76 feat(db): add DbClient.supports_describe_schema + describe_schema probe
5
+
6
+ ## Files changed
7
+ agctl/clients/db_client.py | 30 +++++++++++++++++++
8
+ tests/unit/test_db_client.py | 71 ++++++++++++++++++++++++++++++++++++++++++++
9
+ 2 files changed, 101 insertions(+)
10
+
11
+ ## Diff
12
+ diff --git a/agctl/clients/db_client.py b/agctl/clients/db_client.py
13
+ index b985315..56ef21e 100644
14
+ --- a/agctl/clients/db_client.py
15
+ +++ b/agctl/clients/db_client.py
16
+ @@ -100,12 +100,42 @@ class DbClient:
17
+ The dict returned by the driver's ``execute_write`` method.
18
+ """
19
+ execute_write_attr = getattr(self._driver, "execute_write", None)
20
+ if not callable(execute_write_attr):
21
+ raise ConfigError(
22
+ f"connection's driver ({self._conn_dict['type']}) does not support writes",
23
+ {"driver": self._conn_dict["type"]},
24
+ )
25
+ return self._driver.execute_write(sql, params)
26
+
27
+ + def supports_describe_schema(self) -> bool:
28
+ + """Return True if the driver offers a callable ``describe_schema``.
29
+ +
30
+ + This is a **pre-connect**, side-effect-free probe: it does not call
31
+ + ``describe_schema`` and does not require a connection. Callers
32
+ + (``agctl db schema``) use it to fail fast when the selected driver
33
+ + cannot introspect, without opening a connection.
34
+ +
35
+ + Returns:
36
+ + True if the driver has a callable ``describe_schema`` attribute.
37
+ + """
38
+ + return callable(getattr(self._driver, "describe_schema", None))
39
+ +
40
+ + def describe_schema(self, table: str | None, schema: str | None) -> dict:
41
+ + """Delegate to the driver's optional ``describe_schema`` capability.
42
+ +
43
+ + Probes :meth:`supports_describe_schema`; raises ConfigError if the
44
+ + driver lacks this optional capability. Otherwise delegates to the
45
+ + driver and returns its dict unchanged.
46
+ +
47
+ + Returns:
48
+ + The dict returned by the driver's ``describe_schema`` method.
49
+ + """
50
+ + if not self.supports_describe_schema():
51
+ + raise ConfigError(
52
+ + f"connection's driver ({self._conn_dict['type']}) does not support schema discovery",
53
+ + {"driver": self._conn_dict["type"]},
54
+ + )
55
+ + return self._driver.describe_schema(table, schema)
56
+ +
57
+ def close(self) -> None:
58
+ self._driver.close()
59
+ diff --git a/tests/unit/test_db_client.py b/tests/unit/test_db_client.py
60
+ index 9824992..03cfce3 100644
61
+ --- a/tests/unit/test_db_client.py
62
+ +++ b/tests/unit/test_db_client.py
63
+ @@ -40,20 +40,42 @@ class FakeDriverReadOnly(FakeDriver):
64
+
65
+ class FakeDriverWithNonCallableExecuteWrite(FakeDriver):
66
+ """Fake driver with non-callable execute_write attribute."""
67
+
68
+ def __init__(self):
69
+ super().__init__()
70
+ # Set execute_write to a non-callable value
71
+ self.execute_write = "not a method"
72
+
73
+
74
+ +class FakeDriverWithDescribeSchema(FakeDriver):
75
+ + """Fake driver that supports schema discovery."""
76
+ +
77
+ + SCHEMA = {
78
+ + "tables": [
79
+ + {"name": "users", "schema": "public", "columns": [{"name": "id"}]}
80
+ + ]
81
+ + }
82
+ +
83
+ + def describe_schema(self, table, schema):
84
+ + return self.SCHEMA
85
+ +
86
+ +
87
+ +class FakeDriverWithNonCallableDescribeSchema(FakeDriver):
88
+ + """Fake driver with non-callable describe_schema attribute."""
89
+ +
90
+ + def __init__(self):
91
+ + super().__init__()
92
+ + # Set describe_schema to a non-callable value
93
+ + self.describe_schema = "not a method"
94
+ +
95
+ +
96
+ class FakeDriverSubclass(FakeDriver):
97
+ """Distinct class so tests can assert which driver was selected."""
98
+
99
+
100
+ class TestDbClientDirectInjection:
101
+ def test_execute_returns_driver_rows(self):
102
+ client = DbClient(
103
+ DatabaseConnection(type="postgresql", host="h"), driver=FakeDriver()
104
+ )
105
+ rows = client.execute("SELECT 1", {})
106
+ @@ -162,10 +184,59 @@ class TestExecuteWrite:
107
+ def test_driver_with_non_callable_execute_write_raises_config_error(self):
108
+ """Driver with non-callable execute_write attribute: also raises ConfigError."""
109
+ client = DbClient(
110
+ DatabaseConnection(type="postgresql", host="h"),
111
+ driver=FakeDriverWithNonCallableExecuteWrite(),
112
+ )
113
+ with pytest.raises(ConfigError) as exc_info:
114
+ client.execute_write("INSERT INTO t (x) VALUES (1)", {"x": 1})
115
+ assert "does not support writes" in exc_info.value.message
116
+ assert exc_info.value.detail.get("driver") == "postgresql"
117
+ +
118
+ +
119
+ +class TestDescribeSchema:
120
+ + """Tests for DbClient.supports_describe_schema + describe_schema probe."""
121
+ +
122
+ + def test_driver_with_describe_schema_reports_support_and_delegates(self):
123
+ + """Driver WITH callable describe_schema: probe True, delegation returns dict unchanged."""
124
+ + client = DbClient(
125
+ + DatabaseConnection(type="postgresql", host="h"),
126
+ + driver=FakeDriverWithDescribeSchema(),
127
+ + )
128
+ + assert client.supports_describe_schema() is True
129
+ + result = client.describe_schema(None, None)
130
+ + assert result == FakeDriverWithDescribeSchema.SCHEMA
131
+ +
132
+ + def test_driver_without_describe_schema_reports_no_support_and_raises(self):
133
+ + """Driver WITHOUT describe_schema: probe False, raises ConfigError with driver type."""
134
+ + client = DbClient(
135
+ + DatabaseConnection(type="postgresql", host="h"),
136
+ + driver=FakeDriverReadOnly(),
137
+ + )
138
+ + assert client.supports_describe_schema() is False
139
+ + with pytest.raises(ConfigError) as exc_info:
140
+ + client.describe_schema(None, None)
141
+ + assert "does not support schema discovery" in exc_info.value.message
142
+ + assert exc_info.value.detail.get("driver") == "postgresql"
143
+ +
144
+ + def test_driver_with_non_callable_describe_schema_raises_config_error(self):
145
+ + """Driver with non-callable describe_schema attribute: probe False, raises ConfigError."""
146
+ + client = DbClient(
147
+ + DatabaseConnection(type="postgresql", host="h"),
148
+ + driver=FakeDriverWithNonCallableDescribeSchema(),
149
+ + )
150
+ + assert client.supports_describe_schema() is False
151
+ + with pytest.raises(ConfigError) as exc_info:
152
+ + client.describe_schema(None, None)
153
+ + assert "does not support schema discovery" in exc_info.value.message
154
+ + assert exc_info.value.detail.get("driver") == "postgresql"
155
+ +
156
+ + def test_supports_describe_schema_does_not_open_connection(self):
157
+ + """Pre-connect probe must be side-effect-free: no connection opened."""
158
+ + fake = FakeDriverWithDescribeSchema()
159
+ + client = DbClient(
160
+ + DatabaseConnection(type="postgresql", host="h"),
161
+ + driver=fake,
162
+ + )
163
+ + # Probe before any connect(); the driver must not have been connected.
164
+ + assert client.supports_describe_schema() is True
165
+ + assert fake.connected_with is None