stravinsky 0.2.67__py3-none-any.whl → 0.4.66__py3-none-any.whl

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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (190) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +112 -11
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
mcp_bridge/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.67"
1
+ __version__ = "0.4.66"
@@ -1,18 +1,28 @@
1
1
  # Authentication module
2
- from .token_store import TokenStore, TokenData
3
2
  from .oauth import (
4
- perform_oauth_flow as gemini_oauth_flow,
5
- refresh_access_token as gemini_refresh_token,
6
3
  ANTIGRAVITY_CLIENT_ID,
7
- ANTIGRAVITY_SCOPES,
8
4
  ANTIGRAVITY_HEADERS,
5
+ ANTIGRAVITY_SCOPES,
6
+ )
7
+ from .oauth import (
8
+ perform_oauth_flow as gemini_oauth_flow,
9
+ )
10
+ from .oauth import (
11
+ refresh_access_token as gemini_refresh_token,
9
12
  )
10
13
  from .openai_oauth import (
11
- perform_oauth_flow as openai_oauth_flow,
12
- refresh_access_token as openai_refresh_token,
13
14
  CLIENT_ID as OPENAI_CLIENT_ID,
15
+ )
16
+ from .openai_oauth import (
14
17
  OPENAI_CALLBACK_PORT,
15
18
  )
19
+ from .openai_oauth import (
20
+ perform_oauth_flow as openai_oauth_flow,
21
+ )
22
+ from .openai_oauth import (
23
+ refresh_access_token as openai_refresh_token,
24
+ )
25
+ from .token_store import TokenData, TokenStore
16
26
 
17
27
  __all__ = [
18
28
  # Token Store
mcp_bridge/auth/cli.py CHANGED
@@ -14,16 +14,26 @@ Usage:
14
14
  """
15
15
 
16
16
  import argparse
17
+ import json
17
18
  import sys
18
19
  import time
20
+ from pathlib import Path
19
21
 
20
- from .token_store import TokenStore
21
22
  from ..tools.init import bootstrap_repo
22
- from .oauth import perform_oauth_flow as gemini_oauth, refresh_access_token as gemini_refresh
23
+ from .oauth import perform_oauth_flow as gemini_oauth
24
+ from .oauth import refresh_access_token as gemini_refresh
23
25
  from .openai_oauth import (
24
26
  perform_oauth_flow as openai_oauth,
27
+ )
28
+ from .openai_oauth import (
25
29
  refresh_access_token as openai_refresh,
26
30
  )
31
+ from .token_store import TokenStore
32
+
33
+ try:
34
+ from ..routing import get_provider_tracker
35
+ except ImportError:
36
+ get_provider_tracker = None # type: ignore
27
37
 
28
38
 
29
39
  def cmd_login(provider: str, token_store: TokenStore) -> int:
@@ -34,7 +44,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
34
44
  For OpenAI: Uses OpenAI OAuth (ChatGPT Plus/Pro subscription)
35
45
  """
36
46
  if provider == "gemini":
37
- print(f"Starting Google OAuth for Gemini...")
47
+ print("Starting Google OAuth for Gemini...")
38
48
 
39
49
  try:
40
50
  result = gemini_oauth()
@@ -57,7 +67,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
57
67
  return 1
58
68
 
59
69
  elif provider == "openai":
60
- print(f"Starting OpenAI OAuth for ChatGPT Plus/Pro...")
70
+ print("Starting OpenAI OAuth for ChatGPT Plus/Pro...")
61
71
  print("Note: Requires ChatGPT Plus/Pro subscription and port 1455 available")
62
72
 
63
73
  try:
@@ -72,7 +82,7 @@ def cmd_login(provider: str, token_store: TokenStore) -> int:
72
82
  expires_at=expires_at,
73
83
  )
74
84
 
75
- print(f"\n✓ Successfully authenticated with OpenAI")
85
+ print("\n✓ Successfully authenticated with OpenAI")
76
86
  print(f" Token expires in: {result.expires_in // 60} minutes")
77
87
  return 0
78
88
 
@@ -116,20 +126,156 @@ def cmd_status(token_store: TokenStore) -> int:
116
126
  minutes = (remaining % 3600) // 60
117
127
  print(f" Expires in: {hours}h {minutes}m")
118
128
  else:
119
- print(f" Token expired")
129
+ print(" Token expired")
120
130
 
121
131
  print()
122
132
  return 0
123
133
 
124
134
 
125
135
  def cmd_refresh(provider: str, token_store: TokenStore) -> int:
126
- """Refresh access token for a provider."""
127
- token = token_store.get_token(provider)
128
- if not token or not token.get("refresh_token"):
129
- print(f"No refresh token found for {provider}")
130
- print(f"Please run: python -m mcp_bridge.auth.cli login {provider}")
136
+ """Manually refresh an access token."""
137
+ try:
138
+ token = token_store.get_token(provider)
139
+ if not token:
140
+ print(f"Not logged in to {provider}. Run 'stravinsky-auth login {provider}' first.")
141
+ return 1
142
+
143
+ print(f"Refreshing {provider} access token...")
144
+
145
+ if provider == "gemini":
146
+ result = gemini_refresh(token["refresh_token"])
147
+ elif provider == "openai":
148
+ result = openai_refresh(token["refresh_token"])
149
+ else:
150
+ print(f"Refresh not supported for {provider}")
151
+ return 1
152
+
153
+ expires_at = int(time.time()) + result.expires_in
154
+
155
+ token_store.set_token(
156
+ provider=provider,
157
+ access_token=result.access_token,
158
+ refresh_token=result.refresh_token or token["refresh_token"],
159
+ expires_at=expires_at,
160
+ )
161
+
162
+ print(f"✓ Token refreshed, expires in {result.expires_in // 60} minutes")
163
+ return 0
164
+
165
+ except Exception as e:
166
+ print(f"✗ Refresh failed: {e}", file=sys.stderr)
167
+ return 1
168
+
169
+
170
+ def cmd_routing_status() -> int:
171
+ """Show current routing provider states."""
172
+ if not get_provider_tracker or not callable(get_provider_tracker):
173
+ print("✗ Routing module not available", file=sys.stderr)
131
174
  return 1
132
175
 
176
+ try:
177
+ tracker = get_provider_tracker()
178
+ status = tracker.get_status()
179
+
180
+ print("\n📊 Provider Routing Status\n")
181
+ for provider_name, state in status.items():
182
+ available = "✓ Available" if state["available"] else "⏳ In Cooldown"
183
+ print(f"{provider_name.title():10} {available}")
184
+
185
+ if state["cooldown_remaining"]:
186
+ mins = int(state["cooldown_remaining"] / 60)
187
+ secs = int(state["cooldown_remaining"] % 60)
188
+ print(f" Cooldown: {mins}m {secs}s remaining")
189
+
190
+ print(f" Requests: {state['total_requests']}")
191
+ print(f" Failures: {state['total_failures']}")
192
+
193
+ if state["last_error"]:
194
+ print(f" Last error: {state['last_error']}")
195
+
196
+ print()
197
+
198
+ return 0
199
+
200
+ except Exception as e:
201
+ print(f"✗ Failed to get routing status: {e}", file=sys.stderr)
202
+ return 1
203
+
204
+
205
+ def cmd_routing_reset(provider: str | None = None) -> int:
206
+ """Reset provider cooldown states."""
207
+ if not get_provider_tracker or not callable(get_provider_tracker):
208
+ print("✗ Routing module not available", file=sys.stderr)
209
+ return 1
210
+
211
+ try:
212
+ tracker = get_provider_tracker()
213
+
214
+ if provider:
215
+ tracker.reset_provider(provider)
216
+ print(f"✓ Reset cooldown state for {provider}")
217
+ else:
218
+ tracker.reset_all()
219
+ print("✓ Reset all provider cooldown states")
220
+
221
+ return 0
222
+
223
+ except Exception as e:
224
+ print(f"✗ Failed to reset routing state: {e}", file=sys.stderr)
225
+ return 1
226
+
227
+
228
+ def cmd_routing_init() -> int:
229
+ """Create default routing.json configuration."""
230
+ config_path = Path(".stravinsky/routing.json")
231
+
232
+ if config_path.exists():
233
+ response = input(f"{config_path} already exists. Overwrite? [y/N]: ")
234
+ if response.lower() != "y":
235
+ print("Cancelled.")
236
+ return 0
237
+
238
+ config_path.parent.mkdir(parents=True, exist_ok=True)
239
+
240
+ default_config = {
241
+ "routing": {
242
+ "task_routing": {
243
+ "code_generation": {
244
+ "provider": "openai",
245
+ "model": "gpt-5-codex",
246
+ "description": "Complex code generation tasks",
247
+ },
248
+ "debugging": {
249
+ "provider": "openai",
250
+ "model": "gpt-5-codex",
251
+ "description": "Code analysis and debugging",
252
+ },
253
+ "documentation": {
254
+ "provider": "gemini",
255
+ "model": "gemini-3-flash",
256
+ "description": "Documentation writing",
257
+ },
258
+ "code_search": {
259
+ "provider": "gemini",
260
+ "model": "gemini-3-flash",
261
+ "description": "Finding code patterns",
262
+ },
263
+ },
264
+ "fallback": {
265
+ "enabled": True,
266
+ "chain": ["claude", "openai", "gemini"],
267
+ "cooldown_seconds": 300,
268
+ },
269
+ }
270
+ }
271
+
272
+ with open(config_path, "w") as f:
273
+ json.dump(default_config, f, indent=2)
274
+
275
+ print(f"✓ Created {config_path}")
276
+ print("\nEdit this file to customize routing behavior for this project.")
277
+ return 0
278
+
133
279
  try:
134
280
  print(f"Refreshing {provider} token...")
135
281
 
@@ -226,6 +372,41 @@ def main():
226
372
  description="Creates .stravinsky/ directory structure and copies default configuration files.",
227
373
  )
228
374
 
375
+ # routing command
376
+ routing_parser = subparsers.add_parser(
377
+ "routing",
378
+ help="Manage provider routing and fallback",
379
+ description="View and manage provider routing states, cooldowns, and configuration.",
380
+ )
381
+ routing_subparsers = routing_parser.add_subparsers(
382
+ dest="routing_command", help="Routing subcommands", metavar="SUBCOMMAND"
383
+ )
384
+
385
+ routing_subparsers.add_parser(
386
+ "status",
387
+ help="Show current provider routing states",
388
+ description="Display availability, cooldown timers, and error counts for all providers.",
389
+ )
390
+
391
+ routing_reset_parser = routing_subparsers.add_parser(
392
+ "reset",
393
+ help="Reset provider cooldown states",
394
+ description="Clear cooldown timers for one or all providers.",
395
+ )
396
+ routing_reset_parser.add_argument(
397
+ "provider",
398
+ nargs="?",
399
+ choices=["claude", "openai", "gemini"],
400
+ metavar="PROVIDER",
401
+ help="Provider to reset (omit to reset all)",
402
+ )
403
+
404
+ routing_subparsers.add_parser(
405
+ "init",
406
+ help="Create default routing.json configuration",
407
+ description="Generate .stravinsky/routing.json with default task-based routing rules.",
408
+ )
409
+
229
410
  args = parser.parse_args()
230
411
 
231
412
  if not args.command:
@@ -245,6 +426,16 @@ def main():
245
426
  elif args.command == "init":
246
427
  print(bootstrap_repo())
247
428
  return 0
429
+ elif args.command == "routing":
430
+ if not args.routing_command:
431
+ routing_parser.print_help()
432
+ return 1
433
+ if args.routing_command == "status":
434
+ return cmd_routing_status()
435
+ elif args.routing_command == "reset":
436
+ return cmd_routing_reset(args.provider if hasattr(args, "provider") else None)
437
+ elif args.routing_command == "init":
438
+ return cmd_routing_init()
248
439
 
249
440
  return 0
250
441
 
mcp_bridge/auth/oauth.py CHANGED
@@ -13,13 +13,12 @@ import secrets
13
13
  import threading
14
14
  import webbrowser
15
15
  from dataclasses import dataclass
16
- from http.server import HTTPServer, BaseHTTPRequestHandler
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
17
17
  from typing import Any
18
18
  from urllib.parse import parse_qs, urlencode, urlparse
19
19
 
20
20
  import httpx
21
21
 
22
-
23
22
  # OAuth 2.0 Client Credentials (from constants.ts)
24
23
  ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
25
24
  ANTIGRAVITY_CLIENT_SECRET = "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"
@@ -9,19 +9,16 @@ Port from: https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/li
9
9
 
10
10
  import base64
11
11
  import hashlib
12
- import json
13
12
  import secrets
14
13
  import threading
15
14
  import webbrowser
16
15
  from dataclasses import dataclass
17
- from http.server import HTTPServer, BaseHTTPRequestHandler
16
+ from http.server import BaseHTTPRequestHandler, HTTPServer
18
17
  from typing import Any
19
18
  from urllib.parse import parse_qs, urlencode, urlparse
20
- import time
21
19
 
22
20
  import httpx
23
21
 
24
-
25
22
  # OAuth constants (from openai/codex via opencode-openai-codex-auth)
26
23
  CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
27
24
  AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" # Note: /oauth/authorize
@@ -303,8 +300,8 @@ def perform_oauth_flow(timeout: int = 300) -> TokenResult:
303
300
  try:
304
301
  auth_url, verifier, state = build_auth_url(redirect_uri)
305
302
 
306
- print(f"\n🔐 Opening browser for OpenAI authentication...")
307
- print(f"\nIf browser doesn't open, visit:")
303
+ print("\n🔐 Opening browser for OpenAI authentication...")
304
+ print("\nIf browser doesn't open, visit:")
308
305
  print(f"{auth_url}\n")
309
306
 
310
307
  webbrowser.open(auth_url)
@@ -329,7 +326,7 @@ def perform_oauth_flow(timeout: int = 300) -> TokenResult:
329
326
  print("📝 Exchanging code for tokens...")
330
327
  tokens = exchange_code(result["code"], verifier, redirect_uri)
331
328
 
332
- print(f"✓ Successfully authenticated with OpenAI")
329
+ print("✓ Successfully authenticated with OpenAI")
333
330
 
334
331
  return tokens
335
332
 
@@ -9,9 +9,10 @@ Stores OAuth tokens securely using the OS keyring:
9
9
 
10
10
  import json
11
11
  import time
12
+ from pathlib import Path
12
13
  from typing import TypedDict
13
14
 
14
- import keyring
15
+ from cryptography.fernet import Fernet
15
16
 
16
17
 
17
18
  class TokenData(TypedDict, total=False):
@@ -26,13 +27,15 @@ class TokenData(TypedDict, total=False):
26
27
 
27
28
  class TokenStore:
28
29
  """
29
- Secure storage for OAuth tokens using system keyring.
30
+ Secure storage for OAuth tokens using system keyring with encrypted file fallback.
30
31
 
31
32
  Each provider (gemini, openai) stores its tokens separately.
32
33
  Tokens are serialized as JSON for storage.
34
+ Falls back to encrypted file storage if keyring fails.
33
35
  """
34
36
 
35
37
  SERVICE_NAME = "stravinsky"
38
+ FALLBACK_DIR = Path.home() / ".stravinsky" / "tokens"
36
39
 
37
40
  def __init__(self, service_name: str | None = None):
38
41
  """Initialize the token store.
@@ -41,6 +44,63 @@ class TokenStore:
41
44
  service_name: Override the default service name for testing.
42
45
  """
43
46
  self.service_name = service_name or self.SERVICE_NAME
47
+ self._init_fallback_storage()
48
+
49
+ def _init_fallback_storage(self) -> None:
50
+ """Initialize encrypted file storage fallback directory."""
51
+ self.FALLBACK_DIR.mkdir(parents=True, exist_ok=True)
52
+
53
+ def _get_fallback_path(self, provider: str) -> Path:
54
+ """Get the path for encrypted fallback storage."""
55
+ return self.FALLBACK_DIR / f"{provider}.enc"
56
+
57
+ def _get_or_create_key(self) -> bytes:
58
+ """Get or create encryption key for fallback storage."""
59
+ key_file = self.FALLBACK_DIR / ".key"
60
+ if key_file.exists():
61
+ return key_file.read_bytes()
62
+ # Create new key and save it
63
+ key = Fernet.generate_key()
64
+ key_file.write_bytes(key)
65
+ key_file.chmod(0o600) # Read/write for owner only
66
+ return key
67
+
68
+ def _save_encrypted(self, provider: str, data: str) -> None:
69
+ """Save data to encrypted file."""
70
+ try:
71
+ key = self._get_or_create_key()
72
+ cipher = Fernet(key)
73
+ encrypted = cipher.encrypt(data.encode())
74
+ path = self._get_fallback_path(provider)
75
+ path.write_bytes(encrypted)
76
+ path.chmod(0o600)
77
+ except Exception as e:
78
+ raise RuntimeError(f"Failed to save encrypted token: {e}")
79
+
80
+ def _load_encrypted(self, provider: str) -> str | None:
81
+ """Load data from encrypted file."""
82
+ try:
83
+ path = self._get_fallback_path(provider)
84
+ if not path.exists():
85
+ return None
86
+ key = self._get_or_create_key()
87
+ cipher = Fernet(key)
88
+ encrypted = path.read_bytes()
89
+ decrypted = cipher.decrypt(encrypted)
90
+ return decrypted.decode()
91
+ except Exception:
92
+ return None
93
+
94
+ def _delete_encrypted(self, provider: str) -> bool:
95
+ """Delete encrypted token file."""
96
+ try:
97
+ path = self._get_fallback_path(provider)
98
+ if path.exists():
99
+ path.unlink()
100
+ return True
101
+ return False
102
+ except Exception:
103
+ return False
44
104
 
45
105
  def _key(self, provider: str) -> str:
46
106
  """Generate the keyring key for a provider."""
@@ -56,13 +116,24 @@ class TokenStore:
56
116
  Returns:
57
117
  TokenData if found and valid, None otherwise.
58
118
  """
119
+ # Try keyring first (import inside try to catch backend initialization errors)
59
120
  try:
121
+ import keyring
60
122
  data = keyring.get_password(self.service_name, self._key(provider))
61
- if not data:
62
- return None
63
- return json.loads(data)
64
- except (json.JSONDecodeError, keyring.errors.KeyringError):
65
- return None
123
+ if data:
124
+ return json.loads(data)
125
+ except Exception:
126
+ pass # Fall back to encrypted file (catches KeyringError, import errors, etc.)
127
+
128
+ # Fall back to encrypted file storage
129
+ try:
130
+ data = self._load_encrypted(provider)
131
+ if data:
132
+ return json.loads(data)
133
+ except json.JSONDecodeError:
134
+ pass
135
+
136
+ return None
66
137
 
67
138
  def set_token(
68
139
  self,
@@ -77,6 +148,7 @@ class TokenStore:
77
148
  Store a token for a provider.
78
149
 
79
150
  Can be called with a TokenData dict or individual parameters.
151
+ Falls back to encrypted file storage if keyring fails.
80
152
 
81
153
  Args:
82
154
  provider: The provider name (e.g., 'gemini', 'openai')
@@ -96,7 +168,27 @@ class TokenStore:
96
168
  if expires_at:
97
169
  token_data["expires_at"] = expires_at
98
170
  data = json.dumps(token_data)
99
- keyring.set_password(self.service_name, self._key(provider), data)
171
+
172
+ # Try keyring first, but always also write to encrypted storage
173
+ # Import inside try to catch backend initialization errors (e.g., "No recommended backend")
174
+ keyring_failed = False
175
+ try:
176
+ import keyring
177
+ keyring.set_password(self.service_name, self._key(provider), data)
178
+ except Exception:
179
+ # Keyring failed - fall back to encrypted file
180
+ keyring_failed = True
181
+
182
+ # Always write to encrypted file storage as fallback
183
+ try:
184
+ self._save_encrypted(provider, data)
185
+ except Exception as e:
186
+ # Only fail if both backends failed
187
+ if keyring_failed:
188
+ raise RuntimeError(
189
+ f"Failed to save token to both keyring and encrypted storage: {e}"
190
+ )
191
+
100
192
 
101
193
  def delete_token(self, provider: str) -> bool:
102
194
  """
@@ -108,11 +200,20 @@ class TokenStore:
108
200
  Returns:
109
201
  True if deleted, False if not found.
110
202
  """
203
+ # Try keyring first (import inside try to catch backend initialization errors)
204
+ deleted_from_keyring = False
111
205
  try:
206
+ import keyring
112
207
  keyring.delete_password(self.service_name, self._key(provider))
113
- return True
114
- except keyring.errors.PasswordDeleteError:
115
- return False
208
+ deleted_from_keyring = True
209
+ except Exception:
210
+ # Keyring failed (no backend, delete error, etc.) - continue to encrypted file
211
+ pass
212
+
213
+ # Also delete from encrypted file storage
214
+ deleted_from_file = self._delete_encrypted(provider)
215
+
216
+ return deleted_from_keyring or deleted_from_file
116
217
 
117
218
  def has_valid_token(self, provider: str) -> bool:
118
219
  """
@@ -1,6 +1,6 @@
1
1
  """CLI tools for Stravinsky."""
2
2
 
3
- from .session_report import main as session_report_main
4
3
  from .install_hooks import main as install_hooks_main
4
+ from .session_report import main as session_report_main
5
5
 
6
6
  __all__ = ["session_report_main", "install_hooks_main"]