tsugite-cli 0.25.2__tar.gz → 0.25.4__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 (184) hide show
  1. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/PKG-INFO +1 -1
  2. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/pyproject.toml +1 -1
  3. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/anthropic.py +7 -5
  4. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/base.py +48 -0
  5. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/openai_compat.py +8 -10
  6. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/openrouter.py +2 -3
  7. tsugite_cli-0.25.4/tsugite/secrets/sqlite.py +205 -0
  8. tsugite_cli-0.25.2/tsugite/secrets/sqlite.py +0 -121
  9. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/.gitignore +0 -0
  10. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/AGENTS.md +0 -0
  11. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/CONTRIBUTING.md +0 -0
  12. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/LICENSE +0 -0
  13. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/README.md +0 -0
  14. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/__init__.py +0 -0
  15. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_inheritance.py +0 -0
  16. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_preparation.py +0 -0
  17. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/__init__.py +0 -0
  18. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/exec_directives.py +0 -0
  19. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/exec_runner.py +0 -0
  20. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/helpers.py +0 -0
  21. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/history_integration.py +0 -0
  22. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/metrics.py +0 -0
  23. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/models.py +0 -0
  24. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/runner.py +0 -0
  25. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/steps.py +0 -0
  26. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_runner/validation.py +0 -0
  27. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/agent_utils.py +0 -0
  28. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/approval.py +0 -0
  29. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/__init__.py +0 -0
  30. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/agent_config.py +0 -0
  31. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/auto_context.py +0 -0
  32. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/base.py +0 -0
  33. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/delegation.py +0 -0
  34. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/file.py +0 -0
  35. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/inline.py +0 -0
  36. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/storage.py +0 -0
  37. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/attachments/url.py +0 -0
  38. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/.gitkeep +0 -0
  39. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/code_searcher.md +0 -0
  40. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/default.md +0 -0
  41. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/file_searcher.md +0 -0
  42. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/job_verifier.md +0 -0
  43. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/job_worker.md +0 -0
  44. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_agents/onboard.md +0 -0
  45. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_context.py +0 -0
  46. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/.gitkeep +0 -0
  47. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/codebase-exploration/SKILL.md +0 -0
  48. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/external-agent-bridge/SKILL.md +0 -0
  49. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/python-math/SKILL.md +0 -0
  50. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/response-patterns/SKILL.md +0 -0
  51. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/scheduling/SKILL.md +0 -0
  52. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/skill-authoring/SKILL.md +0 -0
  53. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/tsugite-agent-basics/SKILL.md +0 -0
  54. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/tsugite-jinja-reference/SKILL.md +0 -0
  55. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/builtin_skills/tsugite-skill-basics/SKILL.md +0 -0
  56. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cache.py +0 -0
  57. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cancellation.py +0 -0
  58. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/__init__.py +0 -0
  59. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/agents.py +0 -0
  60. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/attachments.py +0 -0
  61. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/cache.py +0 -0
  62. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/chat.py +0 -0
  63. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/config.py +0 -0
  64. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/daemon.py +0 -0
  65. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/exec.py +0 -0
  66. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/helpers.py +0 -0
  67. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/history.py +0 -0
  68. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/init.py +0 -0
  69. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/models.py +0 -0
  70. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/plugins.py +0 -0
  71. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/render.py +0 -0
  72. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/run.py +0 -0
  73. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/secrets.py +0 -0
  74. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/skills.py +0 -0
  75. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/tools.py +0 -0
  76. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/usage.py +0 -0
  77. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/validate.py +0 -0
  78. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/cli/workspace.py +0 -0
  79. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/config.py +0 -0
  80. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/console.py +0 -0
  81. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/constants.py +0 -0
  82. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/context.py +0 -0
  83. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/context_block.py +0 -0
  84. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/__init__.py +0 -0
  85. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/agent.py +0 -0
  86. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/content_blocks.py +0 -0
  87. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/executor.py +0 -0
  88. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/executor_registry.py +0 -0
  89. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/memory.py +0 -0
  90. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/proxy.py +0 -0
  91. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/record_store.py +0 -0
  92. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/sandbox.py +0 -0
  93. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/state.py +0 -0
  94. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/subprocess_executor.py +0 -0
  95. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/core/tools.py +0 -0
  96. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/daemon_client.py +0 -0
  97. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/events/__init__.py +0 -0
  98. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/events/base.py +0 -0
  99. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/events/bus.py +0 -0
  100. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/events/events.py +0 -0
  101. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/events/helpers.py +0 -0
  102. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/exceptions.py +0 -0
  103. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/__init__.py +0 -0
  104. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/base.py +0 -0
  105. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/models.py +0 -0
  106. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/reconstruction.py +0 -0
  107. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/registry.py +0 -0
  108. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/sqlite_backend.py +0 -0
  109. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/sqlite_conn.py +0 -0
  110. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/sqlite_schema.py +0 -0
  111. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/history/ui_events.py +0 -0
  112. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/hooks.py +0 -0
  113. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/interaction.py +0 -0
  114. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/md_agents.py +0 -0
  115. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/models.py +0 -0
  116. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/options.py +0 -0
  117. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/permissions.py +0 -0
  118. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/plugins.py +0 -0
  119. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/prompt_xml.py +0 -0
  120. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/__init__.py +0 -0
  121. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/model_cache.py +0 -0
  122. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/model_registry.py +0 -0
  123. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/providers/ollama.py +0 -0
  124. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/py.typed +0 -0
  125. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/renderer.py +0 -0
  126. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/schemas/__init__.py +0 -0
  127. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/schemas/agent.schema.json +0 -0
  128. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/__init__.py +0 -0
  129. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/backend.py +0 -0
  130. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/env.py +0 -0
  131. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/exec.py +0 -0
  132. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/file.py +0 -0
  133. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/masking.py +0 -0
  134. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/redaction.py +0 -0
  135. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/secrets/registry.py +0 -0
  136. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/shell_tool_config.py +0 -0
  137. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/skill_discovery.py +0 -0
  138. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/AGENTS.md +0 -0
  139. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/IDENTITY.md +0 -0
  140. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/MEMORY.md +0 -0
  141. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/USER.md +0 -0
  142. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/personas/casual-technical.md +0 -0
  143. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/personas/marvin.md +0 -0
  144. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/templates/personas/minimal.md +0 -0
  145. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/__init__.py +0 -0
  146. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/agents.py +0 -0
  147. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/artifacts.py +0 -0
  148. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/daemon_control.py +0 -0
  149. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/fs.py +0 -0
  150. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/history.py +0 -0
  151. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/http.py +0 -0
  152. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/interactive.py +0 -0
  153. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/jobs.py +0 -0
  154. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/notify.py +0 -0
  155. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/schedule.py +0 -0
  156. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/secrets.py +0 -0
  157. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/sessions.py +0 -0
  158. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/shell.py +0 -0
  159. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/shell_tools.py +0 -0
  160. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/skills.py +0 -0
  161. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tools/time.py +0 -0
  162. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/tsugite.py +0 -0
  163. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/__init__.py +0 -0
  164. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/base.py +0 -0
  165. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/chat.py +0 -0
  166. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/dispatch.py +0 -0
  167. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/helpers.py +0 -0
  168. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/jsonl.py +0 -0
  169. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/plain.py +0 -0
  170. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/repl_chat.py +0 -0
  171. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/repl_commands.py +0 -0
  172. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/repl_completer.py +0 -0
  173. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui/repl_handler.py +0 -0
  174. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui_context.py +0 -0
  175. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/ui_surfaces.py +0 -0
  176. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/usage/__init__.py +0 -0
  177. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/usage/quota.py +0 -0
  178. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/usage/store.py +0 -0
  179. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/user_agent.py +0 -0
  180. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/utils.py +0 -0
  181. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/workspace/__init__.py +0 -0
  182. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/workspace/models.py +0 -0
  183. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/workspace/session.py +0 -0
  184. {tsugite_cli-0.25.2 → tsugite_cli-0.25.4}/tsugite/workspace/templates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tsugite-cli
3
- Version: 0.25.2
3
+ Version: 0.25.4
4
4
  Summary: Micro-agent runner for task automation using markdown definitions
5
5
  Author: Justyn Shull
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tsugite-cli"
3
- version = "0.25.2"
3
+ version = "0.25.4"
4
4
  description = "Micro-agent runner for task automation using markdown definitions"
5
5
  authors = [{ name = "Justyn Shull" }]
6
6
  requires-python = ">=3.11"
@@ -5,7 +5,6 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- import os
9
8
  import re
10
9
  from typing import Any, AsyncIterator
11
10
 
@@ -13,7 +12,7 @@ import httpx
13
12
 
14
13
  from tsugite.user_agent import set_user_agent_header
15
14
 
16
- from .base import CompletionResponse, ModelInfo, StreamChunk, Usage, default_count_tokens
15
+ from .base import CompletionResponse, ModelInfo, StreamChunk, Usage, default_count_tokens, resolve_api_key
17
16
  from .model_registry import calculate_cost, register_models
18
17
  from .model_registry import get_model_info as _get_model_info
19
18
 
@@ -63,9 +62,11 @@ _ANTHROPIC_MODELS: dict[str, ModelInfo] = {
63
62
  class AnthropicProvider:
64
63
  """Provider for the Anthropic Messages API."""
65
64
 
65
+ api_key_env = "ANTHROPIC_API_KEY"
66
+
66
67
  def __init__(self, name: str = "anthropic", api_key: str | None = None, api_base: str | None = None):
67
68
  self.name = name
68
- self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY")
69
+ self.api_key = api_key
69
70
  self.api_base = (api_base or API_BASE).rstrip("/")
70
71
  self._client: httpx.AsyncClient | None = None
71
72
  self._client_loop = None
@@ -92,8 +93,9 @@ class AnthropicProvider:
92
93
  "anthropic-version": API_VERSION,
93
94
  }
94
95
  set_user_agent_header(headers)
95
- if self.api_key:
96
- headers["x-api-key"] = self.api_key
96
+ api_key = self.api_key or resolve_api_key(self.api_key_env)
97
+ if api_key:
98
+ headers["x-api-key"] = api_key
97
99
  return headers
98
100
 
99
101
  async def acompletion(
@@ -2,11 +2,20 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
6
+ import os
7
+ import shutil
8
+ import time
5
9
  from dataclasses import dataclass
6
10
  from typing import Any, AsyncIterator, Protocol, runtime_checkable
7
11
 
8
12
  _O200K_PREFIXES = ("gpt-4o", "o1", "o3", "o4", "chatgpt-4o")
9
13
  _tiktoken_cache: dict[str, Any] = {}
14
+ API_KEY_CACHE_TTL_SECONDS = 60
15
+ _secret_api_keys: dict[str, tuple[float, str | None]] = {}
16
+ _backend_errors_logged: set[str] = set()
17
+
18
+ logger = logging.getLogger(__name__)
10
19
 
11
20
 
12
21
  @dataclass
@@ -100,3 +109,42 @@ def default_count_tokens(text: str, model: str) -> int:
100
109
  return len(_tiktoken_cache[encoding_name].encode(text))
101
110
  except Exception:
102
111
  return len(text) // 4
112
+
113
+
114
+ def api_key_secret_name(env_var: str) -> str:
115
+ """Secrets-backend name for the key read from ``env_var``."""
116
+ return env_var.lower().replace("_", "-")
117
+
118
+
119
+ def resolve_api_key(env_var: str) -> str | None:
120
+ """Key from the secrets backend, else ``env_var``. Backend lookups are cached for ``API_KEY_CACHE_TTL_SECONDS``."""
121
+ now = time.monotonic()
122
+ cached = _secret_api_keys.get(env_var)
123
+ if cached is None or now - cached[0] >= API_KEY_CACHE_TTL_SECONDS:
124
+ from tsugite.secrets import get_backend
125
+
126
+ try:
127
+ _secret_api_keys[env_var] = (now, get_backend().get(api_key_secret_name(env_var)))
128
+ except Exception as e:
129
+ if env_var not in _backend_errors_logged:
130
+ _backend_errors_logged.add(env_var)
131
+ logger.warning("Secrets backend failed reading the key for %s, using the env var: %s", env_var, e)
132
+ return os.getenv(env_var)
133
+ return _secret_api_keys[env_var][1] or os.getenv(env_var)
134
+
135
+
136
+ def invalidate_api_key_cache() -> None:
137
+ _secret_api_keys.clear()
138
+ _backend_errors_logged.clear()
139
+
140
+
141
+ def configured_binary(provider: str) -> str | None:
142
+ from tsugite.config import load_config
143
+
144
+ return load_config().plugins.get(provider, {}).get("binary") or None
145
+
146
+
147
+ def resolve_binary(provider: str, default: str) -> str | None:
148
+ """Path of a CLI provider's executable from config ``plugins.<provider>.binary``, else ``default`` on PATH."""
149
+ configured = configured_binary(provider)
150
+ return shutil.which(os.path.expanduser(configured) if configured else default)
@@ -5,14 +5,13 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import logging
8
- import os
9
8
  from typing import Any, AsyncIterator
10
9
 
11
10
  import httpx
12
11
 
13
12
  from tsugite.user_agent import set_user_agent_header
14
13
 
15
- from .base import CompletionResponse, ModelInfo, StreamChunk, Usage, default_count_tokens
14
+ from .base import CompletionResponse, ModelInfo, StreamChunk, Usage, default_count_tokens, resolve_api_key
16
15
  from .model_registry import calculate_cost, register_models
17
16
  from .model_registry import get_model_info as _get_model_info
18
17
 
@@ -122,10 +121,12 @@ class OpenAICompatProvider:
122
121
  api_base: str,
123
122
  api_key: str | None = None,
124
123
  extra_headers: dict[str, str] | None = None,
124
+ api_key_env: str | None = None,
125
125
  ):
126
126
  self.name = name
127
127
  self.api_base = api_base.rstrip("/")
128
128
  self.api_key = api_key
129
+ self.api_key_env = api_key_env
129
130
  self.extra_headers = extra_headers or {}
130
131
  self._client: httpx.AsyncClient | None = None
131
132
  self._client_loop = None
@@ -148,8 +149,9 @@ class OpenAICompatProvider:
148
149
  headers: dict[str, str] = {"Content-Type": "application/json"}
149
150
  set_user_agent_header(headers)
150
151
  headers.update(self.extra_headers)
151
- if self.api_key:
152
- headers["Authorization"] = f"Bearer {self.api_key}"
152
+ api_key = self.api_key or (resolve_api_key(self.api_key_env) if self.api_key_env else None)
153
+ if api_key:
154
+ headers["Authorization"] = f"Bearer {api_key}"
153
155
  return headers
154
156
 
155
157
  async def acompletion(
@@ -282,16 +284,12 @@ def create_provider(name: str = "openai", **kwargs: Any) -> OpenAICompatProvider
282
284
 
283
285
  api_base = kwargs.get("api_base") or config.get("api_base") or "https://api.openai.com/v1"
284
286
 
285
- api_key = kwargs.get("api_key")
286
- if not api_key:
287
- env_key = config.get("api_key_env")
288
- api_key = os.getenv(env_key) if env_key else None
289
-
290
287
  extra_headers = {**config.get("extra_headers", {}), **kwargs.get("extra_headers", {})}
291
288
 
292
289
  return OpenAICompatProvider(
293
290
  name=name,
294
291
  api_base=api_base,
295
- api_key=api_key,
292
+ api_key=kwargs.get("api_key"),
296
293
  extra_headers=extra_headers,
294
+ api_key_env=config.get("api_key_env"),
297
295
  )
@@ -59,11 +59,10 @@ class OpenRouterProvider(OpenAICompatProvider):
59
59
 
60
60
 
61
61
  def create_provider(name: str = "openrouter", **kwargs: Any) -> OpenRouterProvider:
62
- import os
63
-
64
62
  return OpenRouterProvider(
65
63
  name=name,
66
64
  api_base=kwargs.get("api_base") or "https://openrouter.ai/api/v1",
67
- api_key=kwargs.get("api_key") or os.getenv("OPENROUTER_API_KEY"),
65
+ api_key=kwargs.get("api_key"),
68
66
  extra_headers=kwargs.get("extra_headers", {}),
67
+ api_key_env="OPENROUTER_API_KEY",
69
68
  )
@@ -0,0 +1,205 @@
1
+ """SQLite secret backend with Fernet encryption."""
2
+
3
+ import base64
4
+ import getpass
5
+ import os
6
+ import secrets
7
+ import shutil
8
+ import sqlite3
9
+ import subprocess
10
+ import sys
11
+ import threading
12
+ from pathlib import Path
13
+
14
+ KEYCHAIN_SERVICE = "tsugite"
15
+ # `security find-generic-password` exit status for errSecItemNotFound.
16
+ MACOS_ITEM_NOT_FOUND = 44
17
+
18
+
19
+ class SqliteSecretBackend:
20
+ """Stores secrets in SQLite, encrypted with Fernet. A passphrase is always required."""
21
+
22
+ def __init__(self, config: dict):
23
+ from tsugite.config import get_xdg_data_path
24
+
25
+ path = config.get("path") or str(get_xdg_data_path("secrets") / "secrets.db")
26
+ self._db_path = Path(path)
27
+ self._conn: sqlite3.Connection | None = None
28
+ self._key_cache: dict[bytes, bytes] = {}
29
+ self._keychain_account = (config.get("keychain_account") or "secrets") if config.get("keychain") else None
30
+ if self._keychain_account and any(c in self._keychain_account for c in '"\\\r\n'):
31
+ raise ValueError("keychain_account must not contain quotes, backslashes or line breaks")
32
+ self._keychain_lock = threading.Lock()
33
+ self._passphrase_value = self._resolve_passphrase(config)
34
+ if not self._passphrase_value and not self._keychain_account:
35
+ raise ValueError(
36
+ "sqlite secrets backend requires a passphrase. Set TSUGITE_SECRETS_KEY env var, "
37
+ "configure key_file or keychain in secrets config, or use an interactive terminal."
38
+ )
39
+
40
+ def _get_conn(self) -> sqlite3.Connection:
41
+ if self._conn is None:
42
+ self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
43
+ new_db = not self._db_path.exists()
44
+ self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
45
+ if new_db:
46
+ self._db_path.chmod(0o600)
47
+ self._conn.execute("PRAGMA journal_mode=WAL")
48
+ self._conn.execute("CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, value TEXT NOT NULL)")
49
+ self._conn.commit()
50
+ return self._conn
51
+
52
+ def _resolve_passphrase(self, config: dict) -> str | None:
53
+ env_key = os.environ.get("TSUGITE_SECRETS_KEY")
54
+ if env_key:
55
+ return env_key
56
+ key_file = config.get("key_file")
57
+ if key_file:
58
+ kf = Path(key_file).expanduser()
59
+ if kf.is_file():
60
+ return kf.read_text().strip()
61
+ if config.get("keychain"):
62
+ return None
63
+ if config.get("passphrase"):
64
+ return config["passphrase"]
65
+ if sys.stdin.isatty():
66
+ passphrase = getpass.getpass("Secrets passphrase: ")
67
+ return passphrase or None
68
+ return None
69
+
70
+ @property
71
+ def _passphrase(self) -> str:
72
+ with self._keychain_lock:
73
+ if self._passphrase_value is None:
74
+ self._passphrase_value = self._keychain_passphrase()
75
+ return self._passphrase_value
76
+
77
+ def _keychain_passphrase(self) -> str:
78
+ import fcntl
79
+
80
+ self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
81
+ with open(self._db_path.with_name(self._db_path.name + ".lock"), "a") as lock:
82
+ fcntl.flock(lock, fcntl.LOCK_EX)
83
+ return self._lookup_or_create_key()
84
+
85
+ def _lookup_or_create_key(self) -> str:
86
+ account = self._keychain_account
87
+ key = _keychain_lookup(account)
88
+ if key:
89
+ return key
90
+ if self._get_conn().execute("SELECT 1 FROM secrets LIMIT 1").fetchone():
91
+ raise ValueError(
92
+ f"The OS keychain has no secrets key (service '{KEYCHAIN_SERVICE}', account '{account}'), "
93
+ f"but {self._db_path} already holds encrypted secrets. Restore the key to the keychain "
94
+ "or set TSUGITE_SECRETS_KEY."
95
+ )
96
+ key = secrets.token_urlsafe(32)
97
+ _keychain_store(account, key)
98
+ if _keychain_lookup(account) != key:
99
+ raise ValueError(
100
+ f"Stored the secrets key in the OS keychain (account '{account}') but could not read it back"
101
+ )
102
+ return key
103
+
104
+ def _derive_key(self, salt: bytes) -> bytes:
105
+ cached = self._key_cache.get(salt)
106
+ if cached:
107
+ return cached
108
+ from cryptography.hazmat.primitives import hashes
109
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
110
+
111
+ kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=480_000)
112
+ key = base64.urlsafe_b64encode(kdf.derive(self._passphrase.encode()))
113
+ self._key_cache[salt] = key
114
+ return key
115
+
116
+ def _encrypt(self, value: str) -> str:
117
+ from cryptography.fernet import Fernet
118
+
119
+ salt = os.urandom(16)
120
+ key = self._derive_key(salt)
121
+ token = Fernet(key).encrypt(value.encode())
122
+ return base64.b64encode(salt + token).decode()
123
+
124
+ def _decrypt(self, blob: str) -> str:
125
+ from cryptography.fernet import Fernet, InvalidToken
126
+
127
+ try:
128
+ raw = base64.b64decode(blob)
129
+ except Exception as e:
130
+ raise ValueError(f"Corrupted secret value: {e}") from e
131
+ if len(raw) < 17:
132
+ raise ValueError("Corrupted secret value: too short")
133
+ salt, token = raw[:16], raw[16:]
134
+ key = self._derive_key(salt)
135
+ try:
136
+ return Fernet(key).decrypt(token).decode()
137
+ except InvalidToken:
138
+ raise ValueError("Wrong passphrase or corrupted secret")
139
+
140
+ def get(self, name: str) -> str | None:
141
+ row = self._get_conn().execute("SELECT value FROM secrets WHERE name=?", (name,)).fetchone()
142
+ if row is None:
143
+ return None
144
+ return self._decrypt(row[0])
145
+
146
+ def set(self, name: str, value: str) -> None:
147
+ encrypted = self._encrypt(value)
148
+ conn = self._get_conn()
149
+ conn.execute(
150
+ "INSERT OR REPLACE INTO secrets (name, value) VALUES (?, ?)",
151
+ (name, encrypted),
152
+ )
153
+ conn.commit()
154
+
155
+ def delete(self, name: str) -> bool:
156
+ conn = self._get_conn()
157
+ cursor = conn.execute("DELETE FROM secrets WHERE name=?", (name,))
158
+ conn.commit()
159
+ return cursor.rowcount > 0
160
+
161
+ def list_names(self) -> list[str]:
162
+ rows = self._get_conn().execute("SELECT name FROM secrets ORDER BY name").fetchall()
163
+ return [r[0] for r in rows]
164
+
165
+
166
+ def _keychain_tool() -> str:
167
+ if sys.platform == "darwin":
168
+ return "/usr/bin/security"
169
+ tool = shutil.which("secret-tool")
170
+ if not tool:
171
+ raise ValueError(
172
+ "secrets keychain is enabled but secret-tool was not found. Install libsecret-tools "
173
+ "and run a Secret Service (gnome-keyring, KeePassXC or KWallet)."
174
+ )
175
+ return tool
176
+
177
+
178
+ def _keychain_lookup(account: str) -> str | None:
179
+ tool = _keychain_tool()
180
+ if sys.platform == "darwin":
181
+ cmd = [tool, "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account, "-w"]
182
+ else:
183
+ cmd = [tool, "lookup", "service", KEYCHAIN_SERVICE, "account", account]
184
+ result = subprocess.run(cmd, capture_output=True, text=True)
185
+ if result.returncode == 0:
186
+ return result.stdout.strip()
187
+ # secret-tool exits 1 with empty stderr for a missing item.
188
+ missing = result.returncode == MACOS_ITEM_NOT_FOUND if sys.platform == "darwin" else not result.stderr.strip()
189
+ if missing:
190
+ return None
191
+ raise ValueError(f"Could not read the secrets key from the OS keychain: {result.stderr.strip()}")
192
+
193
+
194
+ def _keychain_store(account: str, key: str) -> None:
195
+ tool = _keychain_tool()
196
+ if sys.platform == "darwin":
197
+ # argv is visible to other local users through `ps`.
198
+ cmd = [tool, "-i"]
199
+ stdin = f'add-generic-password -s "{KEYCHAIN_SERVICE}" -a "{account}" -w "{key}"\n'
200
+ else:
201
+ cmd = [tool, "store", "--label=Tsugite secrets key", "service", KEYCHAIN_SERVICE, "account", account]
202
+ stdin = key
203
+ result = subprocess.run(cmd, input=stdin, capture_output=True, text=True)
204
+ if result.returncode != 0:
205
+ raise ValueError(f"Could not store the secrets key in the OS keychain: {result.stderr.strip()}")
@@ -1,121 +0,0 @@
1
- """SQLite secret backend with Fernet encryption."""
2
-
3
- import base64
4
- import getpass
5
- import os
6
- import sqlite3
7
- import sys
8
- from pathlib import Path
9
-
10
-
11
- class SqliteSecretBackend:
12
- """Stores secrets in SQLite, encrypted with Fernet. A passphrase is always required."""
13
-
14
- def __init__(self, config: dict):
15
- from tsugite.config import get_xdg_data_path
16
-
17
- path = config.get("path") or str(get_xdg_data_path("secrets") / "secrets.db")
18
- self._db_path = Path(path)
19
- self._conn: sqlite3.Connection | None = None
20
- self._key_cache: dict[bytes, bytes] = {}
21
- self._passphrase = self._resolve_passphrase(
22
- config_passphrase=config.get("passphrase"),
23
- key_file=config.get("key_file"),
24
- )
25
- if not self._passphrase:
26
- raise ValueError(
27
- "sqlite secrets backend requires a passphrase. Set TSUGITE_SECRETS_KEY env var, "
28
- "configure key_file in secrets config, or use an interactive terminal."
29
- )
30
-
31
- def _get_conn(self) -> sqlite3.Connection:
32
- if self._conn is None:
33
- self._db_path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
34
- new_db = not self._db_path.exists()
35
- self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
36
- if new_db:
37
- self._db_path.chmod(0o600)
38
- self._conn.execute("PRAGMA journal_mode=WAL")
39
- self._conn.execute("CREATE TABLE IF NOT EXISTS secrets (name TEXT PRIMARY KEY, value TEXT NOT NULL)")
40
- self._conn.commit()
41
- return self._conn
42
-
43
- @staticmethod
44
- def _resolve_passphrase(
45
- config_passphrase: str | None = None,
46
- key_file: str | None = None,
47
- ) -> str | None:
48
- env_key = os.environ.get("TSUGITE_SECRETS_KEY")
49
- if env_key:
50
- return env_key
51
- if key_file:
52
- kf = Path(key_file).expanduser()
53
- if kf.is_file():
54
- return kf.read_text().strip()
55
- if config_passphrase:
56
- return config_passphrase
57
- if sys.stdin.isatty():
58
- passphrase = getpass.getpass("Secrets passphrase: ")
59
- return passphrase or None
60
- return None
61
-
62
- def _derive_key(self, salt: bytes) -> bytes:
63
- cached = self._key_cache.get(salt)
64
- if cached:
65
- return cached
66
- from cryptography.hazmat.primitives import hashes
67
- from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
68
-
69
- kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=480_000)
70
- key = base64.urlsafe_b64encode(kdf.derive(self._passphrase.encode()))
71
- self._key_cache[salt] = key
72
- return key
73
-
74
- def _encrypt(self, value: str) -> str:
75
- from cryptography.fernet import Fernet
76
-
77
- salt = os.urandom(16)
78
- key = self._derive_key(salt)
79
- token = Fernet(key).encrypt(value.encode())
80
- return base64.b64encode(salt + token).decode()
81
-
82
- def _decrypt(self, blob: str) -> str:
83
- from cryptography.fernet import Fernet, InvalidToken
84
-
85
- try:
86
- raw = base64.b64decode(blob)
87
- except Exception as e:
88
- raise ValueError(f"Corrupted secret value: {e}") from e
89
- if len(raw) < 17:
90
- raise ValueError("Corrupted secret value: too short")
91
- salt, token = raw[:16], raw[16:]
92
- key = self._derive_key(salt)
93
- try:
94
- return Fernet(key).decrypt(token).decode()
95
- except InvalidToken:
96
- raise ValueError("Wrong passphrase or corrupted secret")
97
-
98
- def get(self, name: str) -> str | None:
99
- row = self._get_conn().execute("SELECT value FROM secrets WHERE name=?", (name,)).fetchone()
100
- if row is None:
101
- return None
102
- return self._decrypt(row[0])
103
-
104
- def set(self, name: str, value: str) -> None:
105
- encrypted = self._encrypt(value)
106
- conn = self._get_conn()
107
- conn.execute(
108
- "INSERT OR REPLACE INTO secrets (name, value) VALUES (?, ?)",
109
- (name, encrypted),
110
- )
111
- conn.commit()
112
-
113
- def delete(self, name: str) -> bool:
114
- conn = self._get_conn()
115
- cursor = conn.execute("DELETE FROM secrets WHERE name=?", (name,))
116
- conn.commit()
117
- return cursor.rowcount > 0
118
-
119
- def list_names(self) -> list[str]:
120
- rows = self._get_conn().execute("SELECT name FROM secrets ORDER BY name").fetchall()
121
- return [r[0] for r in rows]
File without changes
File without changes
File without changes
File without changes