specsmith 0.7.0.dev236__tar.gz → 0.8.0.dev237__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 (190) hide show
  1. {specsmith-0.7.0.dev236/src/specsmith.egg-info → specsmith-0.8.0.dev237}/PKG-INFO +1 -1
  2. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/pyproject.toml +1 -1
  3. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/chat_runner.py +98 -1
  4. specsmith-0.8.0.dev237/src/specsmith/agent/endpoints.py +493 -0
  5. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/cli.py +373 -2
  6. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237/src/specsmith.egg-info}/PKG-INFO +1 -1
  7. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/SOURCES.txt +4 -0
  8. specsmith-0.8.0.dev237/tests/test_chat_runner_openai_compat.py +195 -0
  9. specsmith-0.8.0.dev237/tests/test_endpoints_cli.py +244 -0
  10. specsmith-0.8.0.dev237/tests/test_endpoints_store.py +350 -0
  11. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/LICENSE +0 -0
  12. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/README.md +0 -0
  13. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/setup.cfg +0 -0
  14. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/__init__.py +0 -0
  15. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/belief.py +0 -0
  16. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/certainty.py +0 -0
  17. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/failure_graph.py +0 -0
  18. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/py.typed +0 -0
  19. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/recovery.py +0 -0
  20. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/session.py +0 -0
  21. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/stress_tester.py +0 -0
  22. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/epistemic/trace.py +0 -0
  23. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/__init__.py +0 -0
  24. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/__main__.py +0 -0
  25. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/__init__.py +0 -0
  26. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/broker.py +0 -0
  27. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/cleanup.py +0 -0
  28. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/events.py +0 -0
  29. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/indexer.py +0 -0
  30. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/mcp.py +0 -0
  31. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/memory.py +0 -0
  32. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/orchestrator.py +0 -0
  33. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/repl.py +0 -0
  34. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/router.py +0 -0
  35. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/rules.py +0 -0
  36. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/safety.py +0 -0
  37. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/suggester.py +0 -0
  38. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/tools.py +0 -0
  39. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/verifier.py +0 -0
  40. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/agent/voice.py +0 -0
  41. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/architect.py +0 -0
  42. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/auditor.py +0 -0
  43. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/auth.py +0 -0
  44. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/block_export.py +0 -0
  45. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/cloud_serve.py +0 -0
  46. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/commands/__init__.py +0 -0
  47. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/compressor.py +0 -0
  48. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/config.py +0 -0
  49. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/console_utils.py +0 -0
  50. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/credit_analyzer.py +0 -0
  51. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/credits.py +0 -0
  52. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/differ.py +0 -0
  53. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/doctor.py +0 -0
  54. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/drive.py +0 -0
  55. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/__init__.py +0 -0
  56. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/belief.py +0 -0
  57. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/certainty.py +0 -0
  58. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/failure_graph.py +0 -0
  59. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/recovery.py +0 -0
  60. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/epistemic/stress_tester.py +0 -0
  61. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/executor.py +0 -0
  62. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/exporter.py +0 -0
  63. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/__init__.py +0 -0
  64. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/app.py +0 -0
  65. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/main_window.py +0 -0
  66. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/session_tab.py +0 -0
  67. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/theme.py +0 -0
  68. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/__init__.py +0 -0
  69. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/chat_view.py +0 -0
  70. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/input_bar.py +0 -0
  71. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/provider_bar.py +0 -0
  72. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/token_meter.py +0 -0
  73. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/tool_panel.py +0 -0
  74. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/widgets/update_checker.py +0 -0
  75. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/gui/worker.py +0 -0
  76. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/history_search.py +0 -0
  77. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/importer.py +0 -0
  78. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/__init__.py +0 -0
  79. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/agent_skill.py +0 -0
  80. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/aider.py +0 -0
  81. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/base.py +0 -0
  82. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/claude_code.py +0 -0
  83. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/copilot.py +0 -0
  84. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/cursor.py +0 -0
  85. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/gemini.py +0 -0
  86. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/integrations/windsurf.py +0 -0
  87. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/languages.py +0 -0
  88. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/ledger.py +0 -0
  89. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/patent.py +0 -0
  90. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/phase.py +0 -0
  91. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/plugins.py +0 -0
  92. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/profiles.py +0 -0
  93. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/rate_limits.py +0 -0
  94. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/releaser.py +0 -0
  95. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/requirements.py +0 -0
  96. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/requirements_parser.py +0 -0
  97. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/retrieval.py +0 -0
  98. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/scaffolder.py +0 -0
  99. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/serve.py +0 -0
  100. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/session.py +0 -0
  101. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/skills.py +0 -0
  102. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/agents.md.j2 +0 -0
  103. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  104. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  105. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  106. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  107. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  108. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  109. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  110. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/community/security.md.j2 +0 -0
  111. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  112. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
  113. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
  114. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  115. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  116. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/editorconfig.j2 +0 -0
  117. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/gitattributes.j2 +0 -0
  118. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/gitignore.j2 +0 -0
  119. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/go/go.mod.j2 +0 -0
  120. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/go/main.go.j2 +0 -0
  121. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
  122. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  123. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  124. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
  125. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
  126. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
  127. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  128. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  129. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
  130. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
  131. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  132. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/js/package.json.j2 +0 -0
  133. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/ledger.md.j2 +0 -0
  134. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/cli.py.j2 +0 -0
  135. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/init.py.j2 +0 -0
  136. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
  137. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/readme.md.j2 +0 -0
  138. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
  139. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/rust/main.rs.j2 +0 -0
  140. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
  141. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
  142. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  143. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  144. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  145. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  146. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
  147. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/tool_installer.py +0 -0
  148. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/toolrules.py +0 -0
  149. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/tools.py +0 -0
  150. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/trace.py +0 -0
  151. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/updater.py +0 -0
  152. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/upgrader.py +0 -0
  153. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/validator.py +0 -0
  154. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs/__init__.py +0 -0
  155. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs/base.py +0 -0
  156. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs/bitbucket.py +0 -0
  157. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs/github.py +0 -0
  158. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs/gitlab.py +0 -0
  159. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/vcs_commands.py +0 -0
  160. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/wireframes.py +0 -0
  161. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith/workspace.py +0 -0
  162. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/dependency_links.txt +0 -0
  163. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/entry_points.txt +0 -0
  164. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/requires.txt +0 -0
  165. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/src/specsmith.egg-info/top_level.txt +0 -0
  166. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_CMD_001.py +0 -0
  167. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_auditor.py +0 -0
  168. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_chat_diff_decision.py +0 -0
  169. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_chat_stdin_protocol.py +0 -0
  170. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_cli.py +0 -0
  171. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_cli_workflows_history_drive.py +0 -0
  172. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_compressor.py +0 -0
  173. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_e2e_nexus.py +0 -0
  174. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_epistemic.py +0 -0
  175. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_importer.py +0 -0
  176. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_integrations.py +0 -0
  177. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_mcp_client.py +0 -0
  178. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_nexus.py +0 -0
  179. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_phase1_4_new.py +0 -0
  180. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_phase34_completion.py +0 -0
  181. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_rate_limits.py +0 -0
  182. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_scaffolder.py +0 -0
  183. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_skill_marketplace.py +0 -0
  184. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_smoke.py +0 -0
  185. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_suggester.py +0 -0
  186. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_tools.py +0 -0
  187. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_validator.py +0 -0
  188. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_vcs.py +0 -0
  189. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_warp_parity.py +0 -0
  190. {specsmith-0.7.0.dev236 → specsmith-0.8.0.dev237}/tests/test_warp_parity_followup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.7.0.dev236
3
+ Version: 0.8.0.dev237
4
4
  Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
5
5
  Author: BitConcepts
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specsmith"
7
- version = "0.7.0.dev236"
7
+ version = "0.8.0.dev237"
8
8
  description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -80,11 +80,35 @@ def run_chat(
80
80
  history: list[dict[str, Any]] | None = None,
81
81
  confidence_target: float = 0.7,
82
82
  rules_prefix: str = "",
83
+ endpoint_id: str | None = None,
83
84
  ) -> ChatRunResult | None:
84
- """Drive a real LLM turn. Return ``None`` if no provider is reachable."""
85
+ """Drive a real LLM turn. Return ``None`` if no provider is reachable.
86
+
87
+ When ``endpoint_id`` is set, the BYOE store (REQ-142) is consulted and
88
+ the resolved :class:`Endpoint` short-circuits the provider chain via
89
+ the new :func:`_run_openai_compat` driver. Any error during endpoint
90
+ resolution falls back to the legacy auto-detect chain so an offline
91
+ misconfigured endpoint never breaks `specsmith chat`.
92
+ """
85
93
  history = history or []
86
94
  messages = _build_messages(utterance, history, rules_prefix)
87
95
 
96
+ # REQ-142: explicit endpoint override.
97
+ if endpoint_id:
98
+ try:
99
+ from specsmith.agent.endpoints import EndpointStore
100
+
101
+ endpoint = EndpointStore.load().resolve(endpoint_id)
102
+ except Exception: # noqa: BLE001 - any failure → fall back to auto-detect
103
+ endpoint = None
104
+ if endpoint is not None:
105
+ try:
106
+ full_text = _run_openai_compat(messages, emitter, msg_block, endpoint=endpoint)
107
+ except Exception: # noqa: BLE001 - degrade to auto-detect
108
+ full_text = None
109
+ if full_text is not None:
110
+ return _finalize(full_text, "openai_compat", project_dir, confidence_target)
111
+
88
112
  # Order matters: Ollama first because it's local-first and free.
89
113
  for provider in (_run_ollama, _run_anthropic, _run_openai, _run_gemini):
90
114
  try:
@@ -228,6 +252,79 @@ def _run_openai(
228
252
  return "".join(pieces) if pieces else None
229
253
 
230
254
 
255
+ def _run_openai_compat(
256
+ messages: list[dict[str, str]],
257
+ emitter: EventEmitter,
258
+ block_id: str,
259
+ *,
260
+ endpoint: Any,
261
+ ) -> str | None:
262
+ """Stream from a user-registered OpenAI-v1-compatible endpoint (REQ-142).
263
+
264
+ Uses raw stdlib HTTP so the openai SDK is not a hard dependency for
265
+ BYOE. Sends a streaming ``/chat/completions`` request, decodes the
266
+ Server-Sent-Events ``data:`` lines, and forwards each ``content``
267
+ delta as a ``token`` event on ``block_id``.
268
+ """
269
+ base_url = endpoint.base_url.rstrip("/")
270
+ url = f"{base_url}/chat/completions"
271
+ model = endpoint.default_model or os.environ.get("SPECSMITH_OPENAI_COMPAT_MODEL", "")
272
+ if not model:
273
+ # The endpoint did not pin a default model and the env override is
274
+ # absent. We cannot fabricate one; fall back to the auto-detect chain.
275
+ return None
276
+
277
+ headers: dict[str, str] = {
278
+ "Content-Type": "application/json",
279
+ "Accept": "text/event-stream",
280
+ }
281
+ try:
282
+ token = endpoint.resolve_token()
283
+ except Exception: # noqa: BLE001 - fall back to auto-detect chain
284
+ return None
285
+ if token:
286
+ headers["Authorization"] = f"Bearer {token}"
287
+
288
+ body = json.dumps({"model": model, "messages": messages, "stream": True}).encode("utf-8")
289
+ req = Request(url, data=body, headers=headers, method="POST") # noqa: S310 - user-supplied
290
+
291
+ ctx = None
292
+ if not endpoint.verify_tls and url.startswith("https://"):
293
+ import ssl
294
+
295
+ ctx = ssl.create_default_context()
296
+ ctx.check_hostname = False
297
+ ctx.verify_mode = ssl.CERT_NONE
298
+
299
+ pieces: list[str] = []
300
+ try:
301
+ with urlopen(req, timeout=120, context=ctx) as resp: # noqa: S310 - user-supplied
302
+ for raw_line in resp:
303
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
304
+ if not line.startswith("data:"):
305
+ continue
306
+ payload = line[len("data:") :].strip()
307
+ if not payload or payload == "[DONE]":
308
+ if payload == "[DONE]":
309
+ break
310
+ continue
311
+ try:
312
+ obj = json.loads(payload)
313
+ except ValueError:
314
+ continue
315
+ choices = obj.get("choices") or []
316
+ if not choices:
317
+ continue
318
+ delta = (choices[0] or {}).get("delta") or {}
319
+ chunk = str(delta.get("content") or "")
320
+ if chunk:
321
+ emitter.token(block_id, chunk)
322
+ pieces.append(chunk)
323
+ except (URLError, TimeoutError, OSError):
324
+ return None
325
+ return "".join(pieces) if pieces else None
326
+
327
+
231
328
  def _run_gemini(
232
329
  messages: list[dict[str, str]],
233
330
  emitter: EventEmitter,
@@ -0,0 +1,493 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Bring-Your-Own-Endpoint (BYOE) data model and persistence (REQ-142).
4
+
5
+ Specsmith historically hard-coded a closed provider list (``ollama`` /
6
+ ``anthropic`` / ``openai`` / ``gemini`` / ``mistral``). This module
7
+ introduces a generic OpenAI-v1-compatible endpoint store so users can
8
+ register self-hosted vLLM, llama.cpp ``server``, LM Studio, TGI, or any
9
+ other ``/v1/chat/completions``-shaped backend and pick between several
10
+ side-by-side.
11
+
12
+ Storage layout (``~/.specsmith/endpoints.json``):
13
+
14
+ .. code-block:: json
15
+
16
+ {
17
+ "schema_version": 1,
18
+ "default_endpoint_id": "home-vllm",
19
+ "endpoints": [
20
+ {
21
+ "id": "home-vllm",
22
+ "name": "Home vLLM",
23
+ "base_url": "http://10.0.0.4:8000/v1",
24
+ "auth": {"kind": "bearer-keyring",
25
+ "keyring_service": "specsmith",
26
+ "keyring_user": "endpoint:home-vllm"},
27
+ "default_model": "Qwen/Qwen2.5-Coder-32B",
28
+ "verify_tls": true,
29
+ "tags": ["local", "coder"],
30
+ "created_at": "2026-05-01T11:30:17Z"
31
+ }
32
+ ]
33
+ }
34
+
35
+ Tokens are NEVER printed verbatim by anything in this module; ``list_all``
36
+ serialisation routes through :func:`Endpoint.to_public_dict` which
37
+ redacts inline tokens to ``"***"``.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import os
44
+ import time
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+ from typing import Any
48
+
49
+ SCHEMA_VERSION = 1
50
+ DEFAULT_KEYRING_SERVICE = "specsmith"
51
+
52
+ VALID_AUTH_KINDS = ("none", "bearer-inline", "bearer-env", "bearer-keyring")
53
+
54
+
55
+ class EndpointError(RuntimeError):
56
+ """Raised for user-facing endpoint errors (validation, missing token, ...)."""
57
+
58
+
59
+ # ---------------------------------------------------------------------------
60
+ # Data model
61
+ # ---------------------------------------------------------------------------
62
+
63
+
64
+ @dataclass
65
+ class EndpointAuth:
66
+ """Discriminated-union auth metadata.
67
+
68
+ ``kind`` is one of:
69
+
70
+ * ``none`` — no Authorization header (e.g. open vLLM on a trusted LAN).
71
+ * ``bearer-inline`` — token stored verbatim in ``endpoints.json``.
72
+ Only used when the user explicitly opts in; the on-disk plaintext
73
+ is documented as insecure.
74
+ * ``bearer-env`` — token resolved from ``token_env`` at call time.
75
+ * ``bearer-keyring`` — token stored in the OS keyring under
76
+ ``(keyring_service, keyring_user)``.
77
+ """
78
+
79
+ kind: str = "none"
80
+ token: str = "" # only set when kind == "bearer-inline"
81
+ token_env: str = "" # only set when kind == "bearer-env"
82
+ keyring_service: str = DEFAULT_KEYRING_SERVICE
83
+ keyring_user: str = ""
84
+
85
+ def to_dict(self) -> dict[str, Any]:
86
+ """On-disk shape (token included for ``bearer-inline``)."""
87
+ out: dict[str, Any] = {"kind": self.kind}
88
+ if self.kind == "bearer-inline":
89
+ out["token"] = self.token
90
+ elif self.kind == "bearer-env":
91
+ out["token_env"] = self.token_env
92
+ elif self.kind == "bearer-keyring":
93
+ out["keyring_service"] = self.keyring_service
94
+ out["keyring_user"] = self.keyring_user
95
+ return out
96
+
97
+ def to_public_dict(self) -> dict[str, Any]:
98
+ """Redacted shape — never returns inline token bytes."""
99
+ out: dict[str, Any] = {"kind": self.kind}
100
+ if self.kind == "bearer-inline":
101
+ out["token"] = "***"
102
+ elif self.kind == "bearer-env":
103
+ out["token_env"] = self.token_env
104
+ elif self.kind == "bearer-keyring":
105
+ out["keyring_service"] = self.keyring_service
106
+ out["keyring_user"] = self.keyring_user
107
+ return out
108
+
109
+ @classmethod
110
+ def from_dict(cls, raw: dict[str, Any]) -> EndpointAuth:
111
+ kind = str(raw.get("kind") or "none").strip()
112
+ if kind not in VALID_AUTH_KINDS:
113
+ raise EndpointError(f"invalid auth kind {kind!r}; expected one of {VALID_AUTH_KINDS}")
114
+ return cls(
115
+ kind=kind,
116
+ token=str(raw.get("token") or ""),
117
+ token_env=str(raw.get("token_env") or ""),
118
+ keyring_service=str(raw.get("keyring_service") or DEFAULT_KEYRING_SERVICE),
119
+ keyring_user=str(raw.get("keyring_user") or ""),
120
+ )
121
+
122
+
123
+ @dataclass
124
+ class Endpoint:
125
+ """A single OpenAI-v1-compatible endpoint registered for use with specsmith."""
126
+
127
+ id: str
128
+ name: str
129
+ base_url: str
130
+ auth: EndpointAuth = field(default_factory=EndpointAuth)
131
+ default_model: str = ""
132
+ verify_tls: bool = True
133
+ tags: list[str] = field(default_factory=list)
134
+ created_at: str = ""
135
+
136
+ # ── Validation ─────────────────────────────────────────────────────────
137
+
138
+ def validate(self) -> None:
139
+ """Raise :class:`EndpointError` on structural problems."""
140
+ if not self.id or not self.id.strip():
141
+ raise EndpointError("endpoint id must be non-empty")
142
+ if any(c.isspace() for c in self.id):
143
+ raise EndpointError(f"endpoint id {self.id!r} must not contain whitespace")
144
+ if not self.base_url.startswith(("http://", "https://")):
145
+ raise EndpointError(
146
+ f"endpoint base_url {self.base_url!r} must start with http:// or https://"
147
+ )
148
+ if self.auth.kind == "bearer-env" and not self.auth.token_env:
149
+ raise EndpointError("auth.kind == 'bearer-env' requires a non-empty token_env")
150
+ if self.auth.kind == "bearer-keyring" and not self.auth.keyring_user:
151
+ raise EndpointError(
152
+ "auth.kind == 'bearer-keyring' requires a keyring_user (defaults to endpoint:<id>)"
153
+ )
154
+
155
+ # ── Token resolution ───────────────────────────────────────────────────
156
+
157
+ def resolve_token(self) -> str | None:
158
+ """Return the bearer token for this endpoint, or ``None`` for unauthenticated.
159
+
160
+ Order of resolution mirrors :data:`EndpointAuth.kind`. Errors are
161
+ converted to :class:`EndpointError` so callers can surface a clean
162
+ message instead of a stack trace.
163
+ """
164
+ kind = self.auth.kind
165
+ if kind == "none":
166
+ return None
167
+ if kind == "bearer-inline":
168
+ return self.auth.token or None
169
+ if kind == "bearer-env":
170
+ value = os.environ.get(self.auth.token_env, "").strip()
171
+ if not value:
172
+ raise EndpointError(
173
+ f"endpoint {self.id!r} expects token in env var "
174
+ f"{self.auth.token_env!r}, but it is unset"
175
+ )
176
+ return value
177
+ if kind == "bearer-keyring":
178
+ try:
179
+ import keyring
180
+ except Exception as exc: # noqa: BLE001
181
+ raise EndpointError(
182
+ "keyring is not available — install python-keyring or "
183
+ "switch the endpoint to --auth bearer-env"
184
+ ) from exc
185
+ try:
186
+ value = keyring.get_password(self.auth.keyring_service, self.auth.keyring_user)
187
+ except Exception as exc: # noqa: BLE001
188
+ raise EndpointError(f"keyring lookup failed: {exc}") from exc
189
+ if not value:
190
+ raise EndpointError(
191
+ f"endpoint {self.id!r} has no token stored in keyring "
192
+ f"({self.auth.keyring_service}/{self.auth.keyring_user})"
193
+ )
194
+ return str(value)
195
+ raise EndpointError(f"unknown auth kind {kind!r}")
196
+
197
+ # ── Health / discovery ─────────────────────────────────────────────────
198
+
199
+ def health(self, *, timeout: float = 5.0) -> EndpointHealth:
200
+ """Probe ``<base_url>/models`` and return a structured result.
201
+
202
+ Network and HTTP errors are caught — the returned record always has
203
+ ``ok`` populated. ``models`` is empty when the endpoint does not
204
+ expose ``/models``; that is not an error in itself.
205
+ """
206
+ import urllib.error
207
+ import urllib.request
208
+
209
+ url = self.base_url.rstrip("/") + "/models"
210
+ req = urllib.request.Request(url) # noqa: S310 - user-supplied
211
+ try:
212
+ token = self.resolve_token()
213
+ except EndpointError as exc:
214
+ return EndpointHealth(
215
+ ok=False, latency_ms=0.0, models=[], error=str(exc), status_code=None
216
+ )
217
+ if token:
218
+ req.add_header("Authorization", f"Bearer {token}")
219
+ start = time.perf_counter()
220
+ try:
221
+ ctx = None
222
+ if not self.verify_tls and url.startswith("https://"):
223
+ import ssl
224
+
225
+ ctx = ssl.create_default_context()
226
+ ctx.check_hostname = False
227
+ ctx.verify_mode = ssl.CERT_NONE
228
+ with urllib.request.urlopen( # noqa: S310 - user-supplied
229
+ req, timeout=timeout, context=ctx
230
+ ) as resp:
231
+ latency_ms = (time.perf_counter() - start) * 1000.0
232
+ payload = json.loads(resp.read().decode("utf-8"))
233
+ models = _extract_model_ids(payload)
234
+ return EndpointHealth(
235
+ ok=True,
236
+ latency_ms=latency_ms,
237
+ models=models,
238
+ error="",
239
+ status_code=int(resp.status),
240
+ )
241
+ except urllib.error.HTTPError as exc:
242
+ return EndpointHealth(
243
+ ok=False,
244
+ latency_ms=(time.perf_counter() - start) * 1000.0,
245
+ models=[],
246
+ error=f"HTTP {exc.code}",
247
+ status_code=int(exc.code),
248
+ )
249
+ except Exception as exc: # noqa: BLE001
250
+ return EndpointHealth(
251
+ ok=False,
252
+ latency_ms=(time.perf_counter() - start) * 1000.0,
253
+ models=[],
254
+ error=str(exc),
255
+ status_code=None,
256
+ )
257
+
258
+ # ── Serialisation ──────────────────────────────────────────────────────
259
+
260
+ def to_dict(self) -> dict[str, Any]:
261
+ return {
262
+ "id": self.id,
263
+ "name": self.name,
264
+ "base_url": self.base_url,
265
+ "auth": self.auth.to_dict(),
266
+ "default_model": self.default_model,
267
+ "verify_tls": bool(self.verify_tls),
268
+ "tags": list(self.tags),
269
+ "created_at": self.created_at,
270
+ }
271
+
272
+ def to_public_dict(self) -> dict[str, Any]:
273
+ return {
274
+ "id": self.id,
275
+ "name": self.name,
276
+ "base_url": self.base_url,
277
+ "auth": self.auth.to_public_dict(),
278
+ "default_model": self.default_model,
279
+ "verify_tls": bool(self.verify_tls),
280
+ "tags": list(self.tags),
281
+ "created_at": self.created_at,
282
+ }
283
+
284
+ @classmethod
285
+ def from_dict(cls, raw: dict[str, Any]) -> Endpoint:
286
+ return cls(
287
+ id=str(raw.get("id") or "").strip(),
288
+ name=str(raw.get("name") or "").strip(),
289
+ base_url=str(raw.get("base_url") or "").strip(),
290
+ auth=EndpointAuth.from_dict(raw.get("auth") or {}),
291
+ default_model=str(raw.get("default_model") or "").strip(),
292
+ verify_tls=bool(raw.get("verify_tls", True)),
293
+ tags=[str(t) for t in (raw.get("tags") or [])],
294
+ created_at=str(raw.get("created_at") or ""),
295
+ )
296
+
297
+
298
+ @dataclass
299
+ class EndpointHealth:
300
+ """Structured result of :meth:`Endpoint.health`."""
301
+
302
+ ok: bool
303
+ latency_ms: float
304
+ models: list[str]
305
+ error: str = ""
306
+ status_code: int | None = None
307
+
308
+ def to_dict(self) -> dict[str, Any]:
309
+ return {
310
+ "ok": self.ok,
311
+ "latency_ms": round(self.latency_ms, 2),
312
+ "models": list(self.models),
313
+ "error": self.error,
314
+ "status_code": self.status_code,
315
+ }
316
+
317
+
318
+ def _extract_model_ids(payload: Any) -> list[str]:
319
+ """Pull a list of model id strings out of an OpenAI ``/v1/models`` body.
320
+
321
+ Tolerates the two common shapes (``{"data": [{"id": ...}]}`` from real
322
+ OpenAI / vLLM and ``{"models": [...]}`` used by some proxies).
323
+ """
324
+ out: list[str] = []
325
+ if isinstance(payload, dict):
326
+ candidates = payload.get("data") or payload.get("models") or []
327
+ if isinstance(candidates, list):
328
+ for item in candidates:
329
+ if isinstance(item, dict) and "id" in item:
330
+ out.append(str(item["id"]))
331
+ elif isinstance(item, str):
332
+ out.append(item)
333
+ return out
334
+
335
+
336
+ # ---------------------------------------------------------------------------
337
+ # Store
338
+ # ---------------------------------------------------------------------------
339
+
340
+
341
+ def default_store_path() -> Path:
342
+ """Resolve ``~/.specsmith/endpoints.json``, honouring ``SPECSMITH_HOME``."""
343
+ base = os.environ.get("SPECSMITH_HOME", "").strip()
344
+ home = Path(base) if base else Path.home() / ".specsmith"
345
+ return home / "endpoints.json"
346
+
347
+
348
+ @dataclass
349
+ class EndpointStore:
350
+ """Read/write wrapper around ``~/.specsmith/endpoints.json``.
351
+
352
+ Tokens are never logged. Inline tokens (``auth.kind == "bearer-inline"``)
353
+ land in the JSON unchanged, but :meth:`list_public` redacts them. The
354
+ keyring-backed and env-backed paths never store secrets in the JSON at
355
+ all.
356
+ """
357
+
358
+ path: Path
359
+ schema_version: int = SCHEMA_VERSION
360
+ default_endpoint_id: str = ""
361
+ endpoints: list[Endpoint] = field(default_factory=list)
362
+
363
+ # ── I/O ────────────────────────────────────────────────────────────────
364
+
365
+ @classmethod
366
+ def load(cls, path: Path | None = None) -> EndpointStore:
367
+ target = path or default_store_path()
368
+ if not target.exists():
369
+ return cls(path=target)
370
+ try:
371
+ raw = json.loads(target.read_text(encoding="utf-8"))
372
+ except json.JSONDecodeError as exc:
373
+ raise EndpointError(
374
+ f"endpoints store at {target} is corrupted: {exc}. "
375
+ "Move it aside or fix the JSON to continue."
376
+ ) from exc
377
+ if not isinstance(raw, dict):
378
+ raise EndpointError(f"endpoints store at {target} must be a JSON object")
379
+ version = int(raw.get("schema_version") or 0)
380
+ if version != SCHEMA_VERSION:
381
+ raise EndpointError(
382
+ f"endpoints store at {target} uses schema_version={version}; "
383
+ f"this build of specsmith only understands {SCHEMA_VERSION}."
384
+ )
385
+ endpoints_raw = raw.get("endpoints") or []
386
+ if not isinstance(endpoints_raw, list):
387
+ raise EndpointError("endpoints store: 'endpoints' must be a list")
388
+ endpoints = [Endpoint.from_dict(item) for item in endpoints_raw]
389
+ return cls(
390
+ path=target,
391
+ schema_version=version,
392
+ default_endpoint_id=str(raw.get("default_endpoint_id") or ""),
393
+ endpoints=endpoints,
394
+ )
395
+
396
+ def save(self) -> None:
397
+ self.path.parent.mkdir(parents=True, exist_ok=True)
398
+ payload = {
399
+ "schema_version": self.schema_version,
400
+ "default_endpoint_id": self.default_endpoint_id,
401
+ "endpoints": [e.to_dict() for e in self.endpoints],
402
+ }
403
+ self.path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
404
+ # Best-effort lock-down on POSIX
405
+ import contextlib
406
+
407
+ with contextlib.suppress(Exception):
408
+ self.path.chmod(0o600)
409
+
410
+ # ── CRUD ───────────────────────────────────────────────────────────────
411
+
412
+ def add(self, endpoint: Endpoint, *, replace: bool = False) -> None:
413
+ endpoint.validate()
414
+ if not endpoint.created_at:
415
+ endpoint.created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
416
+ existing = self._index(endpoint.id)
417
+ if existing is not None:
418
+ if not replace:
419
+ raise EndpointError(
420
+ f"endpoint {endpoint.id!r} already exists. Use --replace to overwrite."
421
+ )
422
+ self.endpoints[existing] = endpoint
423
+ else:
424
+ self.endpoints.append(endpoint)
425
+ if not self.default_endpoint_id:
426
+ self.default_endpoint_id = endpoint.id
427
+
428
+ def remove(self, endpoint_id: str) -> bool:
429
+ idx = self._index(endpoint_id)
430
+ if idx is None:
431
+ return False
432
+ self.endpoints.pop(idx)
433
+ if self.default_endpoint_id == endpoint_id:
434
+ self.default_endpoint_id = self.endpoints[0].id if self.endpoints else ""
435
+ return True
436
+
437
+ def get(self, endpoint_id: str) -> Endpoint:
438
+ idx = self._index(endpoint_id)
439
+ if idx is None:
440
+ raise EndpointError(f"unknown endpoint id {endpoint_id!r}")
441
+ return self.endpoints[idx]
442
+
443
+ def get_default(self) -> Endpoint | None:
444
+ if not self.default_endpoint_id:
445
+ return None
446
+ idx = self._index(self.default_endpoint_id)
447
+ if idx is None:
448
+ return None
449
+ return self.endpoints[idx]
450
+
451
+ def set_default(self, endpoint_id: str) -> None:
452
+ if self._index(endpoint_id) is None:
453
+ raise EndpointError(f"unknown endpoint id {endpoint_id!r}")
454
+ self.default_endpoint_id = endpoint_id
455
+
456
+ def list_all(self) -> list[Endpoint]:
457
+ return list(self.endpoints)
458
+
459
+ def list_public(self) -> list[dict[str, Any]]:
460
+ return [e.to_public_dict() for e in self.endpoints]
461
+
462
+ def resolve(self, endpoint_id: str | None) -> Endpoint:
463
+ """Return the named endpoint, or the default if ``endpoint_id`` is empty."""
464
+ if endpoint_id:
465
+ return self.get(endpoint_id)
466
+ default = self.get_default()
467
+ if default is None:
468
+ raise EndpointError(
469
+ "no endpoint specified and no default is set. "
470
+ "Run `specsmith endpoints add ...` to register one."
471
+ )
472
+ return default
473
+
474
+ # ── Internals ──────────────────────────────────────────────────────────
475
+
476
+ def _index(self, endpoint_id: str) -> int | None:
477
+ for i, e in enumerate(self.endpoints):
478
+ if e.id == endpoint_id:
479
+ return i
480
+ return None
481
+
482
+
483
+ __all__ = [
484
+ "DEFAULT_KEYRING_SERVICE",
485
+ "Endpoint",
486
+ "EndpointAuth",
487
+ "EndpointError",
488
+ "EndpointHealth",
489
+ "EndpointStore",
490
+ "SCHEMA_VERSION",
491
+ "VALID_AUTH_KINDS",
492
+ "default_store_path",
493
+ ]