ccproxy-api 0.1.6__py3-none-any.whl → 0.2.0__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.
Files changed (481) hide show
  1. ccproxy/api/__init__.py +1 -15
  2. ccproxy/api/app.py +439 -212
  3. ccproxy/api/bootstrap.py +30 -0
  4. ccproxy/api/decorators.py +85 -0
  5. ccproxy/api/dependencies.py +145 -176
  6. ccproxy/api/format_validation.py +54 -0
  7. ccproxy/api/middleware/cors.py +6 -3
  8. ccproxy/api/middleware/errors.py +402 -530
  9. ccproxy/api/middleware/hooks.py +563 -0
  10. ccproxy/api/middleware/normalize_headers.py +59 -0
  11. ccproxy/api/middleware/request_id.py +35 -16
  12. ccproxy/api/middleware/streaming_hooks.py +292 -0
  13. ccproxy/api/routes/__init__.py +5 -14
  14. ccproxy/api/routes/health.py +39 -672
  15. ccproxy/api/routes/plugins.py +277 -0
  16. ccproxy/auth/__init__.py +2 -19
  17. ccproxy/auth/bearer.py +25 -15
  18. ccproxy/auth/dependencies.py +123 -157
  19. ccproxy/auth/exceptions.py +0 -12
  20. ccproxy/auth/manager.py +35 -49
  21. ccproxy/auth/managers/__init__.py +10 -0
  22. ccproxy/auth/managers/base.py +523 -0
  23. ccproxy/auth/managers/base_enhanced.py +63 -0
  24. ccproxy/auth/managers/token_snapshot.py +77 -0
  25. ccproxy/auth/models/base.py +65 -0
  26. ccproxy/auth/models/credentials.py +40 -0
  27. ccproxy/auth/oauth/__init__.py +4 -18
  28. ccproxy/auth/oauth/base.py +533 -0
  29. ccproxy/auth/oauth/cli_errors.py +37 -0
  30. ccproxy/auth/oauth/flows.py +430 -0
  31. ccproxy/auth/oauth/protocol.py +366 -0
  32. ccproxy/auth/oauth/registry.py +408 -0
  33. ccproxy/auth/oauth/router.py +396 -0
  34. ccproxy/auth/oauth/routes.py +186 -113
  35. ccproxy/auth/oauth/session.py +151 -0
  36. ccproxy/auth/oauth/templates.py +342 -0
  37. ccproxy/auth/storage/__init__.py +2 -5
  38. ccproxy/auth/storage/base.py +279 -5
  39. ccproxy/auth/storage/generic.py +134 -0
  40. ccproxy/cli/__init__.py +1 -2
  41. ccproxy/cli/_settings_help.py +351 -0
  42. ccproxy/cli/commands/auth.py +1519 -793
  43. ccproxy/cli/commands/config/commands.py +209 -276
  44. ccproxy/cli/commands/plugins.py +669 -0
  45. ccproxy/cli/commands/serve.py +75 -810
  46. ccproxy/cli/commands/status.py +254 -0
  47. ccproxy/cli/decorators.py +83 -0
  48. ccproxy/cli/helpers.py +22 -60
  49. ccproxy/cli/main.py +359 -10
  50. ccproxy/cli/options/claude_options.py +0 -25
  51. ccproxy/config/__init__.py +7 -11
  52. ccproxy/config/core.py +227 -0
  53. ccproxy/config/env_generator.py +232 -0
  54. ccproxy/config/runtime.py +67 -0
  55. ccproxy/config/security.py +36 -3
  56. ccproxy/config/settings.py +382 -441
  57. ccproxy/config/toml_generator.py +299 -0
  58. ccproxy/config/utils.py +452 -0
  59. ccproxy/core/__init__.py +7 -271
  60. ccproxy/{_version.py → core/_version.py} +16 -3
  61. ccproxy/core/async_task_manager.py +516 -0
  62. ccproxy/core/async_utils.py +47 -14
  63. ccproxy/core/auth/__init__.py +6 -0
  64. ccproxy/core/constants.py +16 -50
  65. ccproxy/core/errors.py +53 -0
  66. ccproxy/core/id_utils.py +20 -0
  67. ccproxy/core/interfaces.py +16 -123
  68. ccproxy/core/logging.py +473 -18
  69. ccproxy/core/plugins/__init__.py +77 -0
  70. ccproxy/core/plugins/cli_discovery.py +211 -0
  71. ccproxy/core/plugins/declaration.py +455 -0
  72. ccproxy/core/plugins/discovery.py +604 -0
  73. ccproxy/core/plugins/factories.py +967 -0
  74. ccproxy/core/plugins/hooks/__init__.py +30 -0
  75. ccproxy/core/plugins/hooks/base.py +58 -0
  76. ccproxy/core/plugins/hooks/events.py +46 -0
  77. ccproxy/core/plugins/hooks/implementations/__init__.py +16 -0
  78. ccproxy/core/plugins/hooks/implementations/formatters/__init__.py +11 -0
  79. ccproxy/core/plugins/hooks/implementations/formatters/json.py +552 -0
  80. ccproxy/core/plugins/hooks/implementations/formatters/raw.py +370 -0
  81. ccproxy/core/plugins/hooks/implementations/http_tracer.py +431 -0
  82. ccproxy/core/plugins/hooks/layers.py +44 -0
  83. ccproxy/core/plugins/hooks/manager.py +186 -0
  84. ccproxy/core/plugins/hooks/registry.py +139 -0
  85. ccproxy/core/plugins/hooks/thread_manager.py +203 -0
  86. ccproxy/core/plugins/hooks/types.py +22 -0
  87. ccproxy/core/plugins/interfaces.py +416 -0
  88. ccproxy/core/plugins/loader.py +166 -0
  89. ccproxy/core/plugins/middleware.py +233 -0
  90. ccproxy/core/plugins/models.py +59 -0
  91. ccproxy/core/plugins/protocol.py +180 -0
  92. ccproxy/core/plugins/runtime.py +519 -0
  93. ccproxy/{observability/context.py → core/request_context.py} +137 -94
  94. ccproxy/core/status_report.py +211 -0
  95. ccproxy/core/transformers.py +13 -8
  96. ccproxy/data/claude_headers_fallback.json +558 -0
  97. ccproxy/data/codex_headers_fallback.json +121 -0
  98. ccproxy/http/__init__.py +30 -0
  99. ccproxy/http/base.py +95 -0
  100. ccproxy/http/client.py +323 -0
  101. ccproxy/http/hooks.py +642 -0
  102. ccproxy/http/pool.py +279 -0
  103. ccproxy/llms/formatters/__init__.py +7 -0
  104. ccproxy/llms/formatters/anthropic_to_openai/__init__.py +55 -0
  105. ccproxy/llms/formatters/anthropic_to_openai/errors.py +65 -0
  106. ccproxy/llms/formatters/anthropic_to_openai/requests.py +356 -0
  107. ccproxy/llms/formatters/anthropic_to_openai/responses.py +153 -0
  108. ccproxy/llms/formatters/anthropic_to_openai/streams.py +1546 -0
  109. ccproxy/llms/formatters/base.py +140 -0
  110. ccproxy/llms/formatters/base_model.py +33 -0
  111. ccproxy/llms/formatters/common/__init__.py +51 -0
  112. ccproxy/llms/formatters/common/identifiers.py +48 -0
  113. ccproxy/llms/formatters/common/streams.py +254 -0
  114. ccproxy/llms/formatters/common/thinking.py +74 -0
  115. ccproxy/llms/formatters/common/usage.py +135 -0
  116. ccproxy/llms/formatters/constants.py +55 -0
  117. ccproxy/llms/formatters/context.py +116 -0
  118. ccproxy/llms/formatters/mapping.py +33 -0
  119. ccproxy/llms/formatters/openai_to_anthropic/__init__.py +55 -0
  120. ccproxy/llms/formatters/openai_to_anthropic/_helpers.py +141 -0
  121. ccproxy/llms/formatters/openai_to_anthropic/errors.py +53 -0
  122. ccproxy/llms/formatters/openai_to_anthropic/requests.py +674 -0
  123. ccproxy/llms/formatters/openai_to_anthropic/responses.py +285 -0
  124. ccproxy/llms/formatters/openai_to_anthropic/streams.py +530 -0
  125. ccproxy/llms/formatters/openai_to_openai/__init__.py +53 -0
  126. ccproxy/llms/formatters/openai_to_openai/_helpers.py +325 -0
  127. ccproxy/llms/formatters/openai_to_openai/errors.py +6 -0
  128. ccproxy/llms/formatters/openai_to_openai/requests.py +388 -0
  129. ccproxy/llms/formatters/openai_to_openai/responses.py +594 -0
  130. ccproxy/llms/formatters/openai_to_openai/streams.py +1832 -0
  131. ccproxy/llms/formatters/utils.py +306 -0
  132. ccproxy/llms/models/__init__.py +9 -0
  133. ccproxy/llms/models/anthropic.py +619 -0
  134. ccproxy/llms/models/openai.py +844 -0
  135. ccproxy/llms/streaming/__init__.py +26 -0
  136. ccproxy/llms/streaming/accumulators.py +1074 -0
  137. ccproxy/llms/streaming/formatters.py +251 -0
  138. ccproxy/{adapters/openai/streaming.py → llms/streaming/processors.py} +193 -240
  139. ccproxy/models/__init__.py +8 -159
  140. ccproxy/models/detection.py +92 -193
  141. ccproxy/models/provider.py +75 -0
  142. ccproxy/plugins/access_log/README.md +32 -0
  143. ccproxy/plugins/access_log/__init__.py +20 -0
  144. ccproxy/plugins/access_log/config.py +33 -0
  145. ccproxy/plugins/access_log/formatter.py +126 -0
  146. ccproxy/plugins/access_log/hook.py +763 -0
  147. ccproxy/plugins/access_log/logger.py +254 -0
  148. ccproxy/plugins/access_log/plugin.py +137 -0
  149. ccproxy/plugins/access_log/writer.py +109 -0
  150. ccproxy/plugins/analytics/README.md +24 -0
  151. ccproxy/plugins/analytics/__init__.py +1 -0
  152. ccproxy/plugins/analytics/config.py +5 -0
  153. ccproxy/plugins/analytics/ingest.py +85 -0
  154. ccproxy/plugins/analytics/models.py +97 -0
  155. ccproxy/plugins/analytics/plugin.py +121 -0
  156. ccproxy/plugins/analytics/routes.py +163 -0
  157. ccproxy/plugins/analytics/service.py +284 -0
  158. ccproxy/plugins/claude_api/README.md +29 -0
  159. ccproxy/plugins/claude_api/__init__.py +10 -0
  160. ccproxy/plugins/claude_api/adapter.py +829 -0
  161. ccproxy/plugins/claude_api/config.py +52 -0
  162. ccproxy/plugins/claude_api/detection_service.py +461 -0
  163. ccproxy/plugins/claude_api/health.py +175 -0
  164. ccproxy/plugins/claude_api/hooks.py +284 -0
  165. ccproxy/plugins/claude_api/models.py +256 -0
  166. ccproxy/plugins/claude_api/plugin.py +298 -0
  167. ccproxy/plugins/claude_api/routes.py +118 -0
  168. ccproxy/plugins/claude_api/streaming_metrics.py +68 -0
  169. ccproxy/plugins/claude_api/tasks.py +84 -0
  170. ccproxy/plugins/claude_sdk/README.md +35 -0
  171. ccproxy/plugins/claude_sdk/__init__.py +80 -0
  172. ccproxy/plugins/claude_sdk/adapter.py +749 -0
  173. ccproxy/plugins/claude_sdk/auth.py +57 -0
  174. ccproxy/{claude_sdk → plugins/claude_sdk}/client.py +63 -39
  175. ccproxy/plugins/claude_sdk/config.py +210 -0
  176. ccproxy/{claude_sdk → plugins/claude_sdk}/converter.py +6 -6
  177. ccproxy/plugins/claude_sdk/detection_service.py +163 -0
  178. ccproxy/{services/claude_sdk_service.py → plugins/claude_sdk/handler.py} +123 -304
  179. ccproxy/plugins/claude_sdk/health.py +113 -0
  180. ccproxy/plugins/claude_sdk/hooks.py +115 -0
  181. ccproxy/{claude_sdk → plugins/claude_sdk}/manager.py +42 -32
  182. ccproxy/{claude_sdk → plugins/claude_sdk}/message_queue.py +8 -8
  183. ccproxy/{models/claude_sdk.py → plugins/claude_sdk/models.py} +64 -16
  184. ccproxy/plugins/claude_sdk/options.py +154 -0
  185. ccproxy/{claude_sdk → plugins/claude_sdk}/parser.py +23 -5
  186. ccproxy/plugins/claude_sdk/plugin.py +269 -0
  187. ccproxy/plugins/claude_sdk/routes.py +104 -0
  188. ccproxy/{claude_sdk → plugins/claude_sdk}/session_client.py +124 -12
  189. ccproxy/plugins/claude_sdk/session_pool.py +700 -0
  190. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_handle.py +48 -43
  191. ccproxy/{claude_sdk → plugins/claude_sdk}/stream_worker.py +22 -18
  192. ccproxy/{claude_sdk → plugins/claude_sdk}/streaming.py +50 -16
  193. ccproxy/plugins/claude_sdk/tasks.py +97 -0
  194. ccproxy/plugins/claude_shared/README.md +18 -0
  195. ccproxy/plugins/claude_shared/__init__.py +12 -0
  196. ccproxy/plugins/claude_shared/model_defaults.py +171 -0
  197. ccproxy/plugins/codex/README.md +35 -0
  198. ccproxy/plugins/codex/__init__.py +6 -0
  199. ccproxy/plugins/codex/adapter.py +635 -0
  200. ccproxy/{config/codex.py → plugins/codex/config.py} +78 -12
  201. ccproxy/plugins/codex/detection_service.py +544 -0
  202. ccproxy/plugins/codex/health.py +162 -0
  203. ccproxy/plugins/codex/hooks.py +263 -0
  204. ccproxy/plugins/codex/model_defaults.py +39 -0
  205. ccproxy/plugins/codex/models.py +263 -0
  206. ccproxy/plugins/codex/plugin.py +275 -0
  207. ccproxy/plugins/codex/routes.py +129 -0
  208. ccproxy/plugins/codex/streaming_metrics.py +324 -0
  209. ccproxy/plugins/codex/tasks.py +106 -0
  210. ccproxy/plugins/codex/utils/__init__.py +1 -0
  211. ccproxy/plugins/codex/utils/sse_parser.py +106 -0
  212. ccproxy/plugins/command_replay/README.md +34 -0
  213. ccproxy/plugins/command_replay/__init__.py +17 -0
  214. ccproxy/plugins/command_replay/config.py +133 -0
  215. ccproxy/plugins/command_replay/formatter.py +432 -0
  216. ccproxy/plugins/command_replay/hook.py +294 -0
  217. ccproxy/plugins/command_replay/plugin.py +161 -0
  218. ccproxy/plugins/copilot/README.md +39 -0
  219. ccproxy/plugins/copilot/__init__.py +11 -0
  220. ccproxy/plugins/copilot/adapter.py +465 -0
  221. ccproxy/plugins/copilot/config.py +155 -0
  222. ccproxy/plugins/copilot/data/copilot_fallback.json +41 -0
  223. ccproxy/plugins/copilot/detection_service.py +255 -0
  224. ccproxy/plugins/copilot/manager.py +275 -0
  225. ccproxy/plugins/copilot/model_defaults.py +284 -0
  226. ccproxy/plugins/copilot/models.py +148 -0
  227. ccproxy/plugins/copilot/oauth/__init__.py +16 -0
  228. ccproxy/plugins/copilot/oauth/client.py +494 -0
  229. ccproxy/plugins/copilot/oauth/models.py +385 -0
  230. ccproxy/plugins/copilot/oauth/provider.py +602 -0
  231. ccproxy/plugins/copilot/oauth/storage.py +170 -0
  232. ccproxy/plugins/copilot/plugin.py +360 -0
  233. ccproxy/plugins/copilot/routes.py +294 -0
  234. ccproxy/plugins/credential_balancer/README.md +124 -0
  235. ccproxy/plugins/credential_balancer/__init__.py +6 -0
  236. ccproxy/plugins/credential_balancer/config.py +270 -0
  237. ccproxy/plugins/credential_balancer/factory.py +415 -0
  238. ccproxy/plugins/credential_balancer/hook.py +51 -0
  239. ccproxy/plugins/credential_balancer/manager.py +587 -0
  240. ccproxy/plugins/credential_balancer/plugin.py +146 -0
  241. ccproxy/plugins/dashboard/README.md +25 -0
  242. ccproxy/plugins/dashboard/__init__.py +1 -0
  243. ccproxy/plugins/dashboard/config.py +8 -0
  244. ccproxy/plugins/dashboard/plugin.py +71 -0
  245. ccproxy/plugins/dashboard/routes.py +67 -0
  246. ccproxy/plugins/docker/README.md +32 -0
  247. ccproxy/{docker → plugins/docker}/__init__.py +3 -0
  248. ccproxy/{docker → plugins/docker}/adapter.py +108 -10
  249. ccproxy/plugins/docker/config.py +82 -0
  250. ccproxy/{docker → plugins/docker}/docker_path.py +4 -3
  251. ccproxy/{docker → plugins/docker}/middleware.py +2 -2
  252. ccproxy/plugins/docker/plugin.py +198 -0
  253. ccproxy/{docker → plugins/docker}/stream_process.py +3 -3
  254. ccproxy/plugins/duckdb_storage/README.md +26 -0
  255. ccproxy/plugins/duckdb_storage/__init__.py +1 -0
  256. ccproxy/plugins/duckdb_storage/config.py +22 -0
  257. ccproxy/plugins/duckdb_storage/plugin.py +128 -0
  258. ccproxy/plugins/duckdb_storage/routes.py +51 -0
  259. ccproxy/plugins/duckdb_storage/storage.py +633 -0
  260. ccproxy/plugins/max_tokens/README.md +38 -0
  261. ccproxy/plugins/max_tokens/__init__.py +12 -0
  262. ccproxy/plugins/max_tokens/adapter.py +235 -0
  263. ccproxy/plugins/max_tokens/config.py +86 -0
  264. ccproxy/plugins/max_tokens/models.py +53 -0
  265. ccproxy/plugins/max_tokens/plugin.py +200 -0
  266. ccproxy/plugins/max_tokens/service.py +271 -0
  267. ccproxy/plugins/max_tokens/token_limits.json +54 -0
  268. ccproxy/plugins/metrics/README.md +35 -0
  269. ccproxy/plugins/metrics/__init__.py +10 -0
  270. ccproxy/{observability/metrics.py → plugins/metrics/collector.py} +20 -153
  271. ccproxy/plugins/metrics/config.py +85 -0
  272. ccproxy/plugins/metrics/grafana/dashboards/ccproxy-dashboard.json +1720 -0
  273. ccproxy/plugins/metrics/hook.py +403 -0
  274. ccproxy/plugins/metrics/plugin.py +268 -0
  275. ccproxy/{observability → plugins/metrics}/pushgateway.py +57 -59
  276. ccproxy/plugins/metrics/routes.py +107 -0
  277. ccproxy/plugins/metrics/tasks.py +117 -0
  278. ccproxy/plugins/oauth_claude/README.md +35 -0
  279. ccproxy/plugins/oauth_claude/__init__.py +14 -0
  280. ccproxy/plugins/oauth_claude/client.py +270 -0
  281. ccproxy/plugins/oauth_claude/config.py +84 -0
  282. ccproxy/plugins/oauth_claude/manager.py +482 -0
  283. ccproxy/plugins/oauth_claude/models.py +266 -0
  284. ccproxy/plugins/oauth_claude/plugin.py +149 -0
  285. ccproxy/plugins/oauth_claude/provider.py +571 -0
  286. ccproxy/plugins/oauth_claude/storage.py +212 -0
  287. ccproxy/plugins/oauth_codex/README.md +38 -0
  288. ccproxy/plugins/oauth_codex/__init__.py +14 -0
  289. ccproxy/plugins/oauth_codex/client.py +224 -0
  290. ccproxy/plugins/oauth_codex/config.py +95 -0
  291. ccproxy/plugins/oauth_codex/manager.py +256 -0
  292. ccproxy/plugins/oauth_codex/models.py +239 -0
  293. ccproxy/plugins/oauth_codex/plugin.py +146 -0
  294. ccproxy/plugins/oauth_codex/provider.py +574 -0
  295. ccproxy/plugins/oauth_codex/storage.py +92 -0
  296. ccproxy/plugins/permissions/README.md +28 -0
  297. ccproxy/plugins/permissions/__init__.py +22 -0
  298. ccproxy/plugins/permissions/config.py +28 -0
  299. ccproxy/{cli/commands/permission_handler.py → plugins/permissions/handlers/cli.py} +49 -25
  300. ccproxy/plugins/permissions/handlers/protocol.py +33 -0
  301. ccproxy/plugins/permissions/handlers/terminal.py +675 -0
  302. ccproxy/{api/routes → plugins/permissions}/mcp.py +34 -7
  303. ccproxy/{models/permissions.py → plugins/permissions/models.py} +65 -1
  304. ccproxy/plugins/permissions/plugin.py +153 -0
  305. ccproxy/{api/routes/permissions.py → plugins/permissions/routes.py} +20 -16
  306. ccproxy/{api/services/permission_service.py → plugins/permissions/service.py} +65 -11
  307. ccproxy/{api → plugins/permissions}/ui/permission_handler_protocol.py +1 -1
  308. ccproxy/{api → plugins/permissions}/ui/terminal_permission_handler.py +66 -10
  309. ccproxy/plugins/pricing/README.md +34 -0
  310. ccproxy/plugins/pricing/__init__.py +6 -0
  311. ccproxy/{pricing → plugins/pricing}/cache.py +7 -6
  312. ccproxy/{config/pricing.py → plugins/pricing/config.py} +32 -6
  313. ccproxy/plugins/pricing/exceptions.py +35 -0
  314. ccproxy/plugins/pricing/loader.py +440 -0
  315. ccproxy/{pricing → plugins/pricing}/models.py +13 -23
  316. ccproxy/plugins/pricing/plugin.py +169 -0
  317. ccproxy/plugins/pricing/service.py +191 -0
  318. ccproxy/plugins/pricing/tasks.py +300 -0
  319. ccproxy/{pricing → plugins/pricing}/updater.py +86 -72
  320. ccproxy/plugins/pricing/utils.py +99 -0
  321. ccproxy/plugins/request_tracer/README.md +40 -0
  322. ccproxy/plugins/request_tracer/__init__.py +7 -0
  323. ccproxy/plugins/request_tracer/config.py +120 -0
  324. ccproxy/plugins/request_tracer/hook.py +415 -0
  325. ccproxy/plugins/request_tracer/plugin.py +255 -0
  326. ccproxy/scheduler/__init__.py +2 -14
  327. ccproxy/scheduler/core.py +26 -41
  328. ccproxy/scheduler/manager.py +63 -107
  329. ccproxy/scheduler/registry.py +6 -32
  330. ccproxy/scheduler/tasks.py +346 -314
  331. ccproxy/services/__init__.py +0 -1
  332. ccproxy/services/adapters/__init__.py +11 -0
  333. ccproxy/services/adapters/base.py +123 -0
  334. ccproxy/services/adapters/chain_composer.py +88 -0
  335. ccproxy/services/adapters/chain_validation.py +44 -0
  336. ccproxy/services/adapters/chat_accumulator.py +200 -0
  337. ccproxy/services/adapters/delta_utils.py +142 -0
  338. ccproxy/services/adapters/format_adapter.py +136 -0
  339. ccproxy/services/adapters/format_context.py +11 -0
  340. ccproxy/services/adapters/format_registry.py +158 -0
  341. ccproxy/services/adapters/http_adapter.py +1045 -0
  342. ccproxy/services/adapters/mock_adapter.py +118 -0
  343. ccproxy/services/adapters/protocols.py +35 -0
  344. ccproxy/services/adapters/simple_converters.py +571 -0
  345. ccproxy/services/auth_registry.py +180 -0
  346. ccproxy/services/cache/__init__.py +6 -0
  347. ccproxy/services/cache/response_cache.py +261 -0
  348. ccproxy/services/cli_detection.py +437 -0
  349. ccproxy/services/config/__init__.py +6 -0
  350. ccproxy/services/config/proxy_configuration.py +111 -0
  351. ccproxy/services/container.py +256 -0
  352. ccproxy/services/factories.py +380 -0
  353. ccproxy/services/handler_config.py +76 -0
  354. ccproxy/services/interfaces.py +298 -0
  355. ccproxy/services/mocking/__init__.py +6 -0
  356. ccproxy/services/mocking/mock_handler.py +291 -0
  357. ccproxy/services/tracing/__init__.py +7 -0
  358. ccproxy/services/tracing/interfaces.py +61 -0
  359. ccproxy/services/tracing/null_tracer.py +57 -0
  360. ccproxy/streaming/__init__.py +23 -0
  361. ccproxy/streaming/buffer.py +1056 -0
  362. ccproxy/streaming/deferred.py +897 -0
  363. ccproxy/streaming/handler.py +117 -0
  364. ccproxy/streaming/interfaces.py +77 -0
  365. ccproxy/streaming/simple_adapter.py +39 -0
  366. ccproxy/streaming/sse.py +109 -0
  367. ccproxy/streaming/sse_parser.py +127 -0
  368. ccproxy/templates/__init__.py +6 -0
  369. ccproxy/templates/plugin_scaffold.py +695 -0
  370. ccproxy/testing/endpoints/__init__.py +33 -0
  371. ccproxy/testing/endpoints/cli.py +215 -0
  372. ccproxy/testing/endpoints/config.py +874 -0
  373. ccproxy/testing/endpoints/console.py +57 -0
  374. ccproxy/testing/endpoints/models.py +100 -0
  375. ccproxy/testing/endpoints/runner.py +1903 -0
  376. ccproxy/testing/endpoints/tools.py +308 -0
  377. ccproxy/testing/mock_responses.py +70 -1
  378. ccproxy/testing/response_handlers.py +20 -0
  379. ccproxy/utils/__init__.py +0 -6
  380. ccproxy/utils/binary_resolver.py +476 -0
  381. ccproxy/utils/caching.py +327 -0
  382. ccproxy/utils/cli_logging.py +101 -0
  383. ccproxy/utils/command_line.py +251 -0
  384. ccproxy/utils/headers.py +228 -0
  385. ccproxy/utils/model_mapper.py +120 -0
  386. ccproxy/utils/startup_helpers.py +95 -342
  387. ccproxy/utils/version_checker.py +279 -6
  388. ccproxy_api-0.2.0.dist-info/METADATA +212 -0
  389. ccproxy_api-0.2.0.dist-info/RECORD +417 -0
  390. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/WHEEL +1 -1
  391. ccproxy_api-0.2.0.dist-info/entry_points.txt +24 -0
  392. ccproxy/__init__.py +0 -4
  393. ccproxy/adapters/__init__.py +0 -11
  394. ccproxy/adapters/base.py +0 -80
  395. ccproxy/adapters/codex/__init__.py +0 -11
  396. ccproxy/adapters/openai/__init__.py +0 -42
  397. ccproxy/adapters/openai/adapter.py +0 -953
  398. ccproxy/adapters/openai/models.py +0 -412
  399. ccproxy/adapters/openai/response_adapter.py +0 -355
  400. ccproxy/adapters/openai/response_models.py +0 -178
  401. ccproxy/api/middleware/headers.py +0 -49
  402. ccproxy/api/middleware/logging.py +0 -180
  403. ccproxy/api/middleware/request_content_logging.py +0 -297
  404. ccproxy/api/middleware/server_header.py +0 -58
  405. ccproxy/api/responses.py +0 -89
  406. ccproxy/api/routes/claude.py +0 -371
  407. ccproxy/api/routes/codex.py +0 -1231
  408. ccproxy/api/routes/metrics.py +0 -1029
  409. ccproxy/api/routes/proxy.py +0 -211
  410. ccproxy/api/services/__init__.py +0 -6
  411. ccproxy/auth/conditional.py +0 -84
  412. ccproxy/auth/credentials_adapter.py +0 -93
  413. ccproxy/auth/models.py +0 -118
  414. ccproxy/auth/oauth/models.py +0 -48
  415. ccproxy/auth/openai/__init__.py +0 -13
  416. ccproxy/auth/openai/credentials.py +0 -166
  417. ccproxy/auth/openai/oauth_client.py +0 -334
  418. ccproxy/auth/openai/storage.py +0 -184
  419. ccproxy/auth/storage/json_file.py +0 -158
  420. ccproxy/auth/storage/keyring.py +0 -189
  421. ccproxy/claude_sdk/__init__.py +0 -18
  422. ccproxy/claude_sdk/options.py +0 -194
  423. ccproxy/claude_sdk/session_pool.py +0 -550
  424. ccproxy/cli/docker/__init__.py +0 -34
  425. ccproxy/cli/docker/adapter_factory.py +0 -157
  426. ccproxy/cli/docker/params.py +0 -274
  427. ccproxy/config/auth.py +0 -153
  428. ccproxy/config/claude.py +0 -348
  429. ccproxy/config/cors.py +0 -79
  430. ccproxy/config/discovery.py +0 -95
  431. ccproxy/config/docker_settings.py +0 -264
  432. ccproxy/config/observability.py +0 -158
  433. ccproxy/config/reverse_proxy.py +0 -31
  434. ccproxy/config/scheduler.py +0 -108
  435. ccproxy/config/server.py +0 -86
  436. ccproxy/config/validators.py +0 -231
  437. ccproxy/core/codex_transformers.py +0 -389
  438. ccproxy/core/http.py +0 -328
  439. ccproxy/core/http_transformers.py +0 -812
  440. ccproxy/core/proxy.py +0 -143
  441. ccproxy/core/validators.py +0 -288
  442. ccproxy/models/errors.py +0 -42
  443. ccproxy/models/messages.py +0 -269
  444. ccproxy/models/requests.py +0 -107
  445. ccproxy/models/responses.py +0 -270
  446. ccproxy/models/types.py +0 -102
  447. ccproxy/observability/__init__.py +0 -51
  448. ccproxy/observability/access_logger.py +0 -457
  449. ccproxy/observability/sse_events.py +0 -303
  450. ccproxy/observability/stats_printer.py +0 -753
  451. ccproxy/observability/storage/__init__.py +0 -1
  452. ccproxy/observability/storage/duckdb_simple.py +0 -677
  453. ccproxy/observability/storage/models.py +0 -70
  454. ccproxy/observability/streaming_response.py +0 -107
  455. ccproxy/pricing/__init__.py +0 -19
  456. ccproxy/pricing/loader.py +0 -251
  457. ccproxy/services/claude_detection_service.py +0 -269
  458. ccproxy/services/codex_detection_service.py +0 -263
  459. ccproxy/services/credentials/__init__.py +0 -55
  460. ccproxy/services/credentials/config.py +0 -105
  461. ccproxy/services/credentials/manager.py +0 -561
  462. ccproxy/services/credentials/oauth_client.py +0 -481
  463. ccproxy/services/proxy_service.py +0 -1827
  464. ccproxy/static/.keep +0 -0
  465. ccproxy/utils/cost_calculator.py +0 -210
  466. ccproxy/utils/disconnection_monitor.py +0 -83
  467. ccproxy/utils/model_mapping.py +0 -199
  468. ccproxy/utils/models_provider.py +0 -150
  469. ccproxy/utils/simple_request_logger.py +0 -284
  470. ccproxy/utils/streaming_metrics.py +0 -199
  471. ccproxy_api-0.1.6.dist-info/METADATA +0 -615
  472. ccproxy_api-0.1.6.dist-info/RECORD +0 -189
  473. ccproxy_api-0.1.6.dist-info/entry_points.txt +0 -4
  474. /ccproxy/{api/middleware/auth.py → auth/models/__init__.py} +0 -0
  475. /ccproxy/{claude_sdk → plugins/claude_sdk}/exceptions.py +0 -0
  476. /ccproxy/{docker → plugins/docker}/models.py +0 -0
  477. /ccproxy/{docker → plugins/docker}/protocol.py +0 -0
  478. /ccproxy/{docker → plugins/docker}/validators.py +0 -0
  479. /ccproxy/{auth/oauth/storage.py → plugins/permissions/handlers/__init__.py} +0 -0
  480. /ccproxy/{api → plugins/permissions}/ui/__init__.py +0 -0
  481. {ccproxy_api-0.1.6.dist-info → ccproxy_api-0.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,669 @@
1
+ """CLI commands for interacting with plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Annotated, Any, cast, get_args, get_origin
9
+
10
+ import typer
11
+ from pydantic import BaseModel, ValidationError
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from ccproxy.config.settings import Settings
16
+ from ccproxy.core.plugins.discovery import (
17
+ PluginDiscovery,
18
+ PluginFilter,
19
+ build_combined_plugin_denylist,
20
+ )
21
+ from ccproxy.core.plugins.interfaces import PluginFactory
22
+ from ccproxy.templates import PluginTemplateType, build_plugin_scaffold
23
+
24
+
25
+ app = typer.Typer(
26
+ name="plugins", help="Manage and inspect plugins.", no_args_is_help=True
27
+ )
28
+
29
+
30
+ PLUGIN_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PluginConfigField:
35
+ """Renderable representation of a plugin configuration field."""
36
+
37
+ name: str
38
+ type_label: str
39
+ default_label: str
40
+ value_label: str
41
+ description: str
42
+ required: bool
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PluginMetadata:
47
+ """Aggregated metadata and configuration for a plugin."""
48
+
49
+ name: str
50
+ version: str | None
51
+ description: str | None
52
+ enabled: bool
53
+ status_reason: str | None
54
+ config_fields: tuple[PluginConfigField, ...]
55
+
56
+
57
+ def _format_annotation(annotation: Any) -> str:
58
+ """Return a human-readable label for a type annotation."""
59
+
60
+ if annotation is None:
61
+ return "Any"
62
+ module = getattr(annotation, "__module__", "")
63
+ if module == "typing":
64
+ return str(annotation).removeprefix("typing.")
65
+ if hasattr(annotation, "__name__"):
66
+ return str(annotation.__name__)
67
+ return str(annotation)
68
+
69
+
70
+ def _format_default(field: Any) -> str:
71
+ """Render default value or factory for display."""
72
+
73
+ default_factory = getattr(field, "default_factory", None)
74
+ if default_factory is not None:
75
+ factory_name = getattr(default_factory, "__name__", repr(default_factory))
76
+ return f"<factory:{factory_name}>"
77
+
78
+ if field.is_required():
79
+ return "required"
80
+
81
+ default_value = getattr(field, "default", None)
82
+ if isinstance(default_value, str):
83
+ return f'"{default_value}"'
84
+ return repr(default_value)
85
+
86
+
87
+ def _format_value(value: Any, indent: int = 0, max_depth: int = 3) -> str:
88
+ """Render an actual configuration value for display with recursive formatting.
89
+
90
+ Args:
91
+ value: The value to format
92
+ indent: Current indentation level
93
+ max_depth: Maximum recursion depth to prevent excessive nesting
94
+
95
+ Returns:
96
+ Formatted string representation
97
+ """
98
+
99
+ if value is None:
100
+ return "—"
101
+
102
+ # Prevent excessive recursion
103
+ if indent >= max_depth:
104
+ return repr(value)
105
+
106
+ # Handle strings
107
+ if isinstance(value, str):
108
+ return f'"{value}"'
109
+
110
+ # Handle Path objects
111
+ if hasattr(value, "__fspath__"):
112
+ return str(value)
113
+
114
+ # Handle booleans and numbers
115
+ if isinstance(value, bool | int | float):
116
+ return str(value)
117
+
118
+ # Handle enums
119
+ if hasattr(value, "__class__") and hasattr(value.__class__, "__members__"):
120
+ return f"{value.__class__.__name__}.{value.name}"
121
+
122
+ # Handle Pydantic models
123
+ if isinstance(value, BaseModel):
124
+ return _format_pydantic_model(value, indent, max_depth)
125
+
126
+ # Handle lists
127
+ if isinstance(value, list | tuple):
128
+ return _format_list(value, indent, max_depth)
129
+
130
+ # Handle dicts
131
+ if isinstance(value, dict):
132
+ return _format_dict(value, indent, max_depth)
133
+
134
+ # Fallback to repr for other types
135
+ return repr(value)
136
+
137
+
138
+ def _format_pydantic_model(model: BaseModel, indent: int, max_depth: int) -> str:
139
+ """Format a Pydantic model recursively."""
140
+ if indent >= max_depth:
141
+ return repr(model)
142
+
143
+ indent_str = " " * (indent + 1)
144
+ lines = [f"{model.__class__.__name__}("]
145
+
146
+ for field_name in model.model_fields:
147
+ field_value = getattr(model, field_name)
148
+ formatted_value = _format_value(field_value, indent + 1, max_depth)
149
+
150
+ # Handle multiline values with proper indentation
151
+ if "\n" in formatted_value:
152
+ # First line goes on same line as field name
153
+ value_lines = formatted_value.split("\n")
154
+ lines.append(f"{indent_str}{field_name}={value_lines[0]}")
155
+ # Subsequent lines maintain their indentation
156
+ for value_line in value_lines[1:]:
157
+ lines.append(value_line)
158
+ # Remove trailing comma from last value line and add it properly
159
+ if lines[-1].endswith(","):
160
+ lines[-1] = lines[-1]
161
+ else:
162
+ lines[-1] = lines[-1] + ","
163
+ else:
164
+ lines.append(f"{indent_str}{field_name}={formatted_value},")
165
+
166
+ lines.append(" " * indent + ")")
167
+ return "\n".join(lines)
168
+
169
+
170
+ def _format_list(
171
+ items: list[Any] | tuple[Any, ...], indent: int, max_depth: int
172
+ ) -> str:
173
+ """Format a list or tuple recursively."""
174
+ if not items:
175
+ return "[]"
176
+
177
+ if indent >= max_depth:
178
+ return repr(items)
179
+
180
+ # For simple types, keep on one line
181
+ if all(isinstance(item, str | int | float | bool | type(None)) for item in items):
182
+ formatted_items = [_format_value(item, indent, max_depth) for item in items]
183
+ return f"[{', '.join(formatted_items)}]"
184
+
185
+ # For complex types, use multi-line format
186
+ indent_str = " " * (indent + 1)
187
+ lines = ["["]
188
+ for item in items:
189
+ formatted_item = _format_value(item, indent + 1, max_depth)
190
+ # If the formatted item is multiline, indent each line
191
+ if "\n" in formatted_item:
192
+ indented_lines = [
193
+ indent_str + line if i == 0 else " " * (indent + 1) + line
194
+ for i, line in enumerate(formatted_item.split("\n"))
195
+ ]
196
+ lines.append("\n".join(indented_lines) + ",")
197
+ else:
198
+ lines.append(f"{indent_str}{formatted_item},")
199
+
200
+ lines.append(" " * indent + "]")
201
+ return "\n".join(lines)
202
+
203
+
204
+ def _format_dict(d: dict[Any, Any], indent: int, max_depth: int) -> str:
205
+ """Format a dictionary recursively."""
206
+ if not d:
207
+ return "{}"
208
+
209
+ if indent >= max_depth:
210
+ return repr(d)
211
+
212
+ indent_str = " " * (indent + 1)
213
+ lines = ["{"]
214
+
215
+ for key, value in d.items():
216
+ formatted_key = _format_value(key, indent, max_depth)
217
+ formatted_value = _format_value(value, indent + 1, max_depth)
218
+
219
+ # If the formatted value is multiline, handle indentation
220
+ if "\n" in formatted_value:
221
+ lines.append(f"{indent_str}{formatted_key}: {formatted_value},")
222
+ else:
223
+ lines.append(f"{indent_str}{formatted_key}: {formatted_value},")
224
+
225
+ lines.append(" " * indent + "}")
226
+ return "\n".join(lines)
227
+
228
+
229
+ def _extract_nested_model_types(
230
+ config_class: type[BaseModel] | None,
231
+ ) -> dict[str, type[BaseModel]]:
232
+ """Extract all nested Pydantic model types from a config class.
233
+
234
+ Returns a dict mapping model class names to their types, in dependency order.
235
+ """
236
+ if config_class is None:
237
+ return {}
238
+
239
+ nested_types: dict[str, type[BaseModel]] = {}
240
+ seen: set[type[BaseModel]] = set()
241
+
242
+ def _extract_from_annotation(annotation: Any) -> None:
243
+ """Recursively extract BaseModel subclasses from type annotations."""
244
+ if annotation is None:
245
+ return
246
+
247
+ # Check if it's a BaseModel subclass
248
+ try:
249
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
250
+ if annotation not in seen:
251
+ seen.add(annotation)
252
+ nested_types[annotation.__name__] = annotation
253
+ # Recursively extract nested types from this model's fields
254
+ for field in annotation.model_fields.values():
255
+ _extract_from_annotation(field.annotation)
256
+ return
257
+ except TypeError:
258
+ pass
259
+
260
+ # Handle generic types (list, dict, Union, etc.)
261
+ origin = get_origin(annotation)
262
+ if origin is not None:
263
+ # Get type arguments
264
+ args = get_args(annotation)
265
+ for arg in args:
266
+ _extract_from_annotation(arg)
267
+
268
+ # Scan all fields
269
+ for field in config_class.model_fields.values():
270
+ _extract_from_annotation(field.annotation)
271
+
272
+ return nested_types
273
+
274
+
275
+ def describe_config_model(
276
+ config_class: type[BaseModel] | None,
277
+ config_instance: BaseModel | None = None,
278
+ ) -> tuple[PluginConfigField, ...]:
279
+ """Convert a plugin config model into display-ready field metadata."""
280
+
281
+ if config_class is None:
282
+ return ()
283
+
284
+ fields_info: list[PluginConfigField] = []
285
+ for field_name, field in config_class.model_fields.items():
286
+ type_label = _format_annotation(field.annotation)
287
+ default_label = _format_default(field)
288
+ description = field.description or ""
289
+ required = field.is_required()
290
+ value_label = "—"
291
+
292
+ if config_instance is not None:
293
+ value = getattr(config_instance, field_name, None)
294
+ value_label = _format_value(value)
295
+
296
+ fields_info.append(
297
+ PluginConfigField(
298
+ name=field_name,
299
+ type_label=type_label,
300
+ default_label=default_label,
301
+ value_label=value_label,
302
+ description=description,
303
+ required=required,
304
+ )
305
+ )
306
+
307
+ return tuple(fields_info)
308
+
309
+
310
+ def _load_all_plugin_factories(
311
+ settings: Settings,
312
+ ) -> tuple[dict[str, PluginFactory], PluginFilter, set[str]]:
313
+ """Load plugin factories without applying filters for inspection."""
314
+
315
+ plugin_dirs = [Path(path) for path in settings.plugin_discovery.directories]
316
+ discovery = PluginDiscovery(plugin_dirs)
317
+
318
+ combined_denylist = build_combined_plugin_denylist(
319
+ getattr(settings, "disabled_plugins", None),
320
+ getattr(settings, "plugins", None),
321
+ )
322
+ filter_config = PluginFilter(
323
+ enabled_plugins=getattr(settings, "enabled_plugins", None),
324
+ disabled_plugins=combined_denylist,
325
+ )
326
+
327
+ factories = discovery.load_entry_point_factories(plugin_filter=None)
328
+
329
+ if not settings.plugins_disable_local_discovery:
330
+ discovery.discover_plugins()
331
+ filesystem_factories = discovery.load_all_factories(plugin_filter=None)
332
+ for name, factory in filesystem_factories.items():
333
+ factories[name] = factory
334
+
335
+ return factories, filter_config, combined_denylist
336
+
337
+
338
+ def _build_config_instance(
339
+ manifest: Any,
340
+ settings: Settings,
341
+ ) -> BaseModel | None:
342
+ """Instantiate the plugin config using current settings."""
343
+
344
+ config_class = getattr(manifest, "config_class", None)
345
+ if config_class is None:
346
+ return None
347
+
348
+ config_data = settings.plugins.get(manifest.name)
349
+ try:
350
+ if config_data is None:
351
+ return config_class() # type: ignore[no-any-return]
352
+ return config_class.model_validate(config_data) # type: ignore[no-any-return]
353
+ except ValidationError:
354
+ # Fall back to defaults to avoid breaking the CLI view
355
+ try:
356
+ return cast(BaseModel, config_class())
357
+ except ValidationError:
358
+ return None
359
+
360
+
361
+ def _derive_status_reason(
362
+ name: str,
363
+ settings: Settings,
364
+ combined_denylist: set[str],
365
+ ) -> str | None:
366
+ """Determine why a plugin is disabled, if applicable."""
367
+
368
+ if name in combined_denylist:
369
+ return "disabled via config"
370
+ if settings.enabled_plugins is not None and name not in set(
371
+ settings.enabled_plugins
372
+ ):
373
+ return "disabled via not allow-listed"
374
+ if not settings.enable_plugins:
375
+ return "disabled via plugin system disabled"
376
+ return None
377
+
378
+
379
+ def _select_scaffold_root(settings: Settings) -> Path:
380
+ """Choose a sensible default root for new plugin scaffolds."""
381
+
382
+ directories = settings.plugin_discovery.directories
383
+ if not directories:
384
+ return Path.cwd()
385
+ for candidate in reversed(directories):
386
+ candidate_path = Path(candidate)
387
+ parts = candidate_path.parts
388
+ if len(parts) >= 2 and parts[-2:] == ("ccproxy", "plugins"):
389
+ continue
390
+ return candidate_path
391
+
392
+ return Path(directories[-1])
393
+
394
+
395
+ def gather_plugin_metadata(settings: Settings) -> tuple[PluginMetadata, ...]:
396
+ """Collect plugin metadata and configuration for CLI display."""
397
+
398
+ factories, filter_config, combined_denylist = _load_all_plugin_factories(settings)
399
+
400
+ metadata_list: list[PluginMetadata] = []
401
+ for name in sorted(factories):
402
+ factory = factories[name]
403
+ manifest = factory.get_manifest()
404
+ config_instance = _build_config_instance(manifest, settings)
405
+ config_fields = describe_config_model(manifest.config_class, config_instance)
406
+ enabled = settings.enable_plugins and filter_config.is_enabled(name)
407
+ status_reason = (
408
+ None
409
+ if enabled
410
+ else _derive_status_reason(name, settings, combined_denylist)
411
+ )
412
+
413
+ metadata_list.append(
414
+ PluginMetadata(
415
+ name=name,
416
+ version=getattr(manifest, "version", None),
417
+ description=getattr(manifest, "description", None),
418
+ enabled=enabled,
419
+ status_reason=status_reason,
420
+ config_fields=config_fields,
421
+ )
422
+ )
423
+
424
+ return tuple(metadata_list)
425
+
426
+
427
+ @app.command(name="list")
428
+ def list_plugins() -> None:
429
+ """List all discovered plugins and high-level details."""
430
+
431
+ console = Console()
432
+ settings_obj = Settings.from_config()
433
+
434
+ plugins = gather_plugin_metadata(settings_obj)
435
+ if not plugins:
436
+ console.print("No plugins found.")
437
+ return
438
+
439
+ table = Table(
440
+ title="Discovered Plugins",
441
+ show_header=True,
442
+ header_style="bold magenta",
443
+ )
444
+ table.add_column("Plugin", style="bold")
445
+ table.add_column("Version", style="cyan")
446
+ table.add_column("Status", style="green")
447
+ table.add_column("Config Fields", style="yellow")
448
+ table.add_column("Description", style="dim")
449
+
450
+ for plugin in plugins:
451
+ status = "Enabled" if plugin.enabled else "Disabled"
452
+ if plugin.status_reason:
453
+ status = f"{status} ({plugin.status_reason})"
454
+ config_count = str(len(plugin.config_fields)) if plugin.config_fields else "0"
455
+ table.add_row(
456
+ plugin.name,
457
+ plugin.version or "unknown",
458
+ status,
459
+ config_count,
460
+ plugin.description or "",
461
+ )
462
+
463
+ console.print(table)
464
+
465
+
466
+ @app.command()
467
+ def settings(
468
+ plugin: str | None = typer.Argument(None, help="Plugin to inspect"),
469
+ ) -> None:
470
+ """Show configuration fields for plugins."""
471
+ from ccproxy.cli._settings_help import print_settings_help
472
+
473
+ console = Console()
474
+ settings_obj = Settings.from_config()
475
+
476
+ plugins = gather_plugin_metadata(settings_obj)
477
+ if not plugins:
478
+ console.print("No plugins found.")
479
+ return
480
+
481
+ if plugin is not None:
482
+ plugins = tuple(p for p in plugins if p.name == plugin)
483
+ if not plugins:
484
+ console.print(f"Plugin '{plugin}' not found.")
485
+ return
486
+
487
+ # Load plugin factories to get config classes
488
+ factories, _filter_config, _combined_denylist = _load_all_plugin_factories(
489
+ settings_obj
490
+ )
491
+
492
+ for plugin_meta in plugins:
493
+ # Get the plugin factory and config
494
+ factory = factories.get(plugin_meta.name)
495
+ if not factory:
496
+ console.print(
497
+ f"[yellow]Warning: Could not load factory for {plugin_meta.name}[/yellow]"
498
+ )
499
+ continue
500
+
501
+ manifest = factory.get_manifest()
502
+ config_class = getattr(manifest, "config_class", None)
503
+
504
+ if not config_class:
505
+ console.print(f" {plugin_meta.name}: No configuration fields declared.")
506
+ continue
507
+
508
+ # Get the config instance
509
+ config_instance = _build_config_instance(manifest, settings_obj)
510
+
511
+ # Use generic settings display
512
+ print_settings_help(
513
+ config_class,
514
+ config_instance,
515
+ version=plugin_meta.version,
516
+ enabled=plugin_meta.enabled,
517
+ )
518
+
519
+
520
+ @app.command()
521
+ def dependencies() -> None:
522
+ """Display how plugin dependencies are managed."""
523
+
524
+ console = Console()
525
+ console.print(
526
+ "Plugin dependencies are managed at the package level (pyproject.toml/extras)."
527
+ )
528
+
529
+
530
+ @app.command()
531
+ def scaffold(
532
+ plugin_name: Annotated[
533
+ str,
534
+ typer.Argument(
535
+ help="New plugin package name (snake_case).",
536
+ ),
537
+ ],
538
+ plugin_type: Annotated[
539
+ PluginTemplateType,
540
+ typer.Option(
541
+ "--type",
542
+ "-t",
543
+ help="Scaffold type to generate (system, provider, auth).",
544
+ case_sensitive=False,
545
+ ),
546
+ ] = PluginTemplateType.SYSTEM,
547
+ description: Annotated[
548
+ str,
549
+ typer.Option(
550
+ "--description",
551
+ "-d",
552
+ help="Plugin description stored in the manifest.",
553
+ ),
554
+ ] = "Custom CCProxy plugin.",
555
+ version: Annotated[
556
+ str,
557
+ typer.Option(
558
+ "--version",
559
+ "-v",
560
+ help="Semver version recorded in the manifest.",
561
+ ),
562
+ ] = "0.1.0",
563
+ output_path: Annotated[
564
+ Path | None,
565
+ typer.Option(
566
+ "--path",
567
+ "-p",
568
+ help="Directory to create the plugin in (defaults to user plugin dir).",
569
+ file_okay=False,
570
+ dir_okay=True,
571
+ writable=True,
572
+ resolve_path=True,
573
+ ),
574
+ ] = None,
575
+ include_tests: Annotated[
576
+ bool,
577
+ typer.Option(
578
+ "--with-tests/--no-tests",
579
+ help="Include placeholder pytest files in the scaffold.",
580
+ ),
581
+ ] = False,
582
+ force: Annotated[
583
+ bool,
584
+ typer.Option(
585
+ "--force/--no-force",
586
+ help="Overwrite existing files when the directory already exists.",
587
+ ),
588
+ ] = False,
589
+ ) -> None:
590
+ """Generate a plugin scaffold to jump-start development."""
591
+
592
+ console = Console()
593
+ settings_obj = Settings.from_config()
594
+ raw_name = plugin_name.strip()
595
+ normalised = raw_name.lower()
596
+
597
+ if not PLUGIN_NAME_PATTERN.match(normalised):
598
+ raise typer.BadParameter(
599
+ "Plugin name must start with a letter and use lowercase, digits, or underscores.",
600
+ param_hint="plugin_name",
601
+ )
602
+
603
+ plugin_name = normalised
604
+
605
+ if output_path is None:
606
+ target_root = _select_scaffold_root(settings_obj)
607
+ else:
608
+ target_root = output_path
609
+
610
+ target_root = target_root.expanduser()
611
+ target_root.mkdir(parents=True, exist_ok=True)
612
+
613
+ target_dir = target_root / plugin_name
614
+ if target_dir.exists():
615
+ has_content = any(target_dir.iterdir())
616
+ if has_content and not force:
617
+ console.print(
618
+ f"[red]Directory {target_dir} already exists. Use --force to overwrite.[/red]"
619
+ )
620
+ raise typer.Exit(code=1)
621
+ else:
622
+ target_dir.mkdir(parents=True, exist_ok=True)
623
+
624
+ try:
625
+ files = build_plugin_scaffold(
626
+ plugin_name=plugin_name,
627
+ description=description,
628
+ version=version,
629
+ template_type=plugin_type,
630
+ include_tests=include_tests,
631
+ )
632
+ except Exception as exc: # pragma: no cover - defensive
633
+ console.print(f"[red]Failed to build scaffold: {exc}[/red]")
634
+ raise typer.Exit(code=1) from exc
635
+
636
+ created: list[tuple[str, str]] = []
637
+ for relative_path, content in files.items():
638
+ destination = target_dir / relative_path
639
+ destination.parent.mkdir(parents=True, exist_ok=True)
640
+ action = "overwrote" if destination.exists() else "created"
641
+ destination.write_text(content, encoding="utf-8")
642
+ created.append((action, relative_path))
643
+
644
+ console.print(
645
+ f"[bold green]Plugin scaffold ready[/bold green] in [cyan]{target_dir}[/cyan]"
646
+ )
647
+ if raw_name != plugin_name:
648
+ console.print(
649
+ f" • Normalised plugin name to [bold]{plugin_name}[/bold] from '{raw_name}'."
650
+ )
651
+ for action, relative_path in created:
652
+ console.print(f" • {action}: {relative_path}")
653
+ console.print(
654
+ " • Update config and runtime files before enabling the plugin.",
655
+ style="dim",
656
+ )
657
+ if settings_obj.plugins_disable_local_discovery:
658
+ console.print(
659
+ " • Local plugin discovery is disabled. Set `plugins_disable_local_discovery = false`"
660
+ " in your config or export `PLUGINS_DISABLE_LOCAL_DISCOVERY=false` to load filesystem"
661
+ " plugins.",
662
+ style="yellow",
663
+ )
664
+ if not settings_obj.enable_plugins:
665
+ console.print(
666
+ " • Plugin system is disabled (`enable_plugins = false`). Update configuration to"
667
+ " load plugins.",
668
+ style="yellow",
669
+ )