crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
crabcode_cli/app.py ADDED
@@ -0,0 +1,776 @@
1
+ """CrabCode CLI entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from typing import Optional
9
+
10
+ import typer
11
+ from crabcode_core.logging_utils import configure_logging, get_logger
12
+
13
+ app = typer.Typer(
14
+ name="crabcode",
15
+ help="CrabCode — AI coding assistant in the terminal",
16
+ add_completion=False,
17
+ )
18
+
19
+ document_engine_app = typer.Typer(
20
+ name="document-engine",
21
+ help="Manage the optional local high-fidelity PDF translation engine.",
22
+ add_completion=False,
23
+ )
24
+ app.add_typer(document_engine_app, name="document-engine")
25
+
26
+ computer_use_app = typer.Typer(
27
+ name="computer-use",
28
+ help="Manage the shared native Computer Use component (automatically used by the CLI).",
29
+ add_completion=False,
30
+ )
31
+ app.add_typer(computer_use_app, name="computer-use")
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ def _configure_utf8_stdio() -> None:
37
+ """Use UTF-8 for terminal and protocol streams on every platform."""
38
+ for stream in (sys.stdin, sys.stdout, sys.stderr):
39
+ reconfigure = getattr(stream, "reconfigure", None)
40
+ if callable(reconfigure):
41
+ reconfigure(encoding="utf-8", errors="replace")
42
+
43
+
44
+ def _computer_use_command(*, permissions: bool = False, json_output: bool = False,
45
+ permission_kind: str | None = None) -> None:
46
+ import json
47
+
48
+ from crabcode_cli.computer_use import ComputerUseError, NativeClient
49
+
50
+ async def run() -> dict:
51
+ client = NativeClient()
52
+ await client.start()
53
+ if permissions:
54
+ if permission_kind:
55
+ await client.request("permissions", {"kind": permission_kind}, timeout=10)
56
+ else:
57
+ await client.request("permissions", timeout=10)
58
+ caps = await client.request("capabilities", timeout=25)
59
+ return {"installed": True, "path": str(client.state_dir), **caps}
60
+
61
+ try:
62
+ status = asyncio.run(run())
63
+ except ComputerUseError as exc:
64
+ if json_output:
65
+ typer.echo(json.dumps({"status": "unavailable", "detail": str(exc)}, ensure_ascii=False))
66
+ else:
67
+ typer.echo(str(exc), err=True)
68
+ raise typer.Exit(1) from exc
69
+ if json_output:
70
+ typer.echo(json.dumps(status, ensure_ascii=False))
71
+ else:
72
+ typer.echo(f"Computer Use: {status['path']}")
73
+ for key, label in (("gui_available", "图形桌面"), ("capture_available", "截图"),
74
+ ("input_available", "输入"), ("ax_available", "辅助功能树")):
75
+ typer.echo(f" {label}: {'可用' if status.get(key) else '不可用'}")
76
+ if not status.get("capture_available") or not status.get("input_available"):
77
+ typer.echo("请检查图形会话及系统权限;macOS 请为 Crab Computer Use 开启录屏和辅助功能。")
78
+
79
+
80
+ @computer_use_app.command("status")
81
+ def computer_use_status_cmd(
82
+ json_output: bool = typer.Option(False, "--json", help="Print capabilities as JSON"),
83
+ ) -> None:
84
+ """Start the installed component and check its current capabilities."""
85
+ _computer_use_command(json_output=json_output)
86
+
87
+
88
+ @computer_use_app.command("permissions")
89
+ def computer_use_permissions_cmd(
90
+ screen_recording: bool = typer.Option(False, "--screen-recording", help="Open Screen Recording with the draggable app card"),
91
+ accessibility: bool = typer.Option(False, "--accessibility", help="Open Accessibility with the draggable app card"),
92
+ ) -> None:
93
+ """Open the macOS permission settings and recording drag guide."""
94
+ if screen_recording and accessibility:
95
+ raise typer.BadParameter("Choose one permission pane")
96
+ kind = "screen_recording" if screen_recording else "accessibility" if accessibility else None
97
+ _computer_use_command(permissions=True, permission_kind=kind)
98
+
99
+
100
+ @document_engine_app.command("status")
101
+ def document_engine_status_cmd(
102
+ json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
103
+ ) -> None:
104
+ """Show the optional BabelDOC engine status."""
105
+ import json
106
+
107
+ from crabcode_gateway.document_engine import document_engine_status
108
+
109
+ status = document_engine_status()
110
+ if json_output:
111
+ typer.echo(json.dumps(status, ensure_ascii=False))
112
+ else:
113
+ typer.echo(f"{status['status']}: {status['detail']}")
114
+
115
+
116
+ @document_engine_app.command("install")
117
+ def document_engine_install_cmd(
118
+ bundle: Optional[str] = typer.Option(
119
+ None,
120
+ "--bundle",
121
+ help="Use an offline bundle path or URL instead of the official BabelDOC source",
122
+ ),
123
+ json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
124
+ ) -> None:
125
+ """Install BabelDOC from its official source, or use an offline bundle."""
126
+ import json
127
+
128
+ from crabcode_gateway.document_engine import install_document_engine
129
+
130
+ def progress(message: str) -> None:
131
+ # Keep stdout as a single machine-readable result in JSON mode while
132
+ # still allowing desktop callers to stream installation progress.
133
+ typer.echo(message, err=json_output)
134
+
135
+ try:
136
+ status = install_document_engine(bundle, progress=progress)
137
+ except Exception as exc:
138
+ if json_output:
139
+ typer.echo(json.dumps({"status": "error", "detail": str(exc)}, ensure_ascii=False))
140
+ else:
141
+ typer.echo(f"安装失败:{exc}", err=True)
142
+ raise typer.Exit(1) from exc
143
+ if json_output:
144
+ typer.echo(json.dumps(status, ensure_ascii=False))
145
+ else:
146
+ typer.echo(status["detail"])
147
+
148
+
149
+ @document_engine_app.command("remove")
150
+ def document_engine_remove_cmd(
151
+ yes: bool = typer.Option(False, "--yes", help="Remove without confirmation"),
152
+ json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON"),
153
+ ) -> None:
154
+ """Remove the managed engine without touching translated project PDFs."""
155
+ import json
156
+
157
+ from crabcode_gateway.document_engine import remove_document_engine
158
+
159
+ if not yes and not typer.confirm("删除本地高精度 PDF 引擎?已生成的译后 PDF 会保留。"):
160
+ raise typer.Abort()
161
+ status = remove_document_engine()
162
+ if json_output:
163
+ typer.echo(json.dumps(status, ensure_ascii=False))
164
+ else:
165
+ typer.echo(status["detail"])
166
+
167
+
168
+ @app.command()
169
+ def main(
170
+ prompt: Optional[str] = typer.Argument(None, help="Prompt to send (pipe mode)"),
171
+ pipe: bool = typer.Option(False, "-p", "--pipe", help="Run in pipe mode (non-interactive)"),
172
+ model: Optional[str] = typer.Option(None, "-m", "--model", help="Model to use"),
173
+ provider: Optional[str] = typer.Option(None, "--provider", help="API provider (anthropic/openai/codex/ollama/gemini/azure/bedrock/vertex/router)"),
174
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="API base URL (for routers)"),
175
+ api_format: Optional[str] = typer.Option(None, "--api-format", help="API format for router (anthropic/openai)"),
176
+ model_profile: Optional[str] = typer.Option(None, "--model-profile", "-M", help="Use a named model from settings.models"),
177
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
178
+ resume: Optional[str] = typer.Option(None, "-r", "--resume", help="Resume a session by ID"),
179
+ continue_last: bool = typer.Option(False, "-c", "--continue", help="Continue most recent session"),
180
+ yolo: bool = typer.Option(False, "--yolo", help="Enable Full Access for this run (skip tool permission prompts)"),
181
+ image: Optional[list[str]] = typer.Option(None, "-i", "--image", help="Image file(s) to attach (repeatable)"),
182
+ ) -> None:
183
+ """CrabCode — AI coding assistant in the terminal."""
184
+ from crabcode_core.types.config import CrabCodeSettings
185
+
186
+ work_dir = cwd or os.getcwd()
187
+
188
+ # Resolve cross-project resumes before loading project settings or creating
189
+ # any cwd-bound runtime resources. CoreSession also rebinds an already
190
+ # initialized session, but startup should take the correct project path
191
+ # from the outset.
192
+ if resume:
193
+ from crabcode_core.session.storage import SessionStorage
194
+
195
+ resolved_storage = SessionStorage.from_session_id(resume)
196
+ if resolved_storage is not None:
197
+ work_dir = resolved_storage.cwd
198
+
199
+ settings = CrabCodeSettings()
200
+ # Keep CLI flags separate from project-file values. CoreSession needs
201
+ # this distinction when an interactive REPL later resumes a session from a
202
+ # different project; otherwise the first project's config is treated as a
203
+ # caller override and shadows the target project's settings.
204
+ explicit_settings = CrabCodeSettings()
205
+
206
+ from crabcode_core.config.manager import ConfigManager
207
+ file_settings = ConfigManager(cwd=work_dir).load()
208
+ for key, val in file_settings.env.items():
209
+ os.environ.setdefault(key, val)
210
+ if file_settings.api.provider:
211
+ settings.api.provider = file_settings.api.provider
212
+ if file_settings.api.model:
213
+ settings.api.model = file_settings.api.model
214
+ if file_settings.api.base_url:
215
+ settings.api.base_url = file_settings.api.base_url
216
+ if file_settings.api.api_key_env:
217
+ settings.api.api_key_env = file_settings.api.api_key_env
218
+ if file_settings.api.codex_auth_path:
219
+ settings.api.codex_auth_path = file_settings.api.codex_auth_path
220
+ if file_settings.api.http_headers:
221
+ settings.api.http_headers = dict(file_settings.api.http_headers)
222
+ if file_settings.api.format:
223
+ settings.api.format = file_settings.api.format
224
+ if not file_settings.api.thinking_enabled:
225
+ settings.api.thinking_enabled = file_settings.api.thinking_enabled
226
+ if file_settings.api.pass_reasoning_content:
227
+ settings.api.pass_reasoning_content = file_settings.api.pass_reasoning_content
228
+ if file_settings.api.reasoning_effort is not None:
229
+ settings.api.reasoning_effort = file_settings.api.reasoning_effort
230
+ if file_settings.api.max_tokens != 16384:
231
+ settings.api.max_tokens = file_settings.api.max_tokens
232
+ if file_settings.api.extra_body:
233
+ settings.api.extra_body = dict(file_settings.api.extra_body)
234
+ if file_settings.api.azure_endpoint:
235
+ settings.api.azure_endpoint = file_settings.api.azure_endpoint
236
+ if file_settings.api.azure_api_version:
237
+ settings.api.azure_api_version = file_settings.api.azure_api_version
238
+ if file_settings.api.azure_deployment:
239
+ settings.api.azure_deployment = file_settings.api.azure_deployment
240
+ if file_settings.ultra_mode:
241
+ settings.ultra_mode = True
242
+ if file_settings.tool_call_timeout is not None:
243
+ settings.tool_call_timeout = file_settings.tool_call_timeout
244
+
245
+ if file_settings.models:
246
+ settings.models = {**file_settings.models, **settings.models}
247
+ if file_settings.default_model and not settings.default_model:
248
+ settings.default_model = file_settings.default_model
249
+
250
+ if file_settings.permissions.default_mode and not settings.permissions.default_mode:
251
+ settings.permissions.default_mode = file_settings.permissions.default_mode
252
+ if file_settings.permissions.run_everything:
253
+ settings.permissions.run_everything = True
254
+ settings.logging = file_settings.logging.model_copy(deep=True)
255
+
256
+ configure_logging(work_dir, settings.logging)
257
+
258
+ if model:
259
+ settings.api.model = model
260
+ explicit_settings.api.model = model
261
+ if provider:
262
+ settings.api.provider = provider
263
+ explicit_settings.api.provider = provider
264
+ if base_url:
265
+ settings.api.base_url = base_url
266
+ explicit_settings.api.base_url = base_url
267
+ if api_format:
268
+ settings.api.format = api_format
269
+ explicit_settings.api.format = api_format
270
+ if model_profile:
271
+ if model_profile in file_settings.models:
272
+ settings.default_model = model_profile
273
+ explicit_settings.default_model = model_profile
274
+ else:
275
+ typer.echo(
276
+ f"Warning: model profile '{model_profile}' not found in settings.models. "
277
+ f"Available: {list(file_settings.models.keys()) or '(none configured)'}",
278
+ err=True,
279
+ )
280
+
281
+ if yolo:
282
+ settings.permissions.default_mode = "run_everything"
283
+ explicit_settings.permissions.default_mode = "run_everything"
284
+
285
+ settings._crabcode_explicit_settings = explicit_settings
286
+
287
+ # Read image files into base64 attachments
288
+ image_attachments: list[dict[str, str]] | None = None
289
+ if image:
290
+ import base64
291
+ import mimetypes
292
+ from pathlib import Path
293
+
294
+ image_attachments = []
295
+ for img_path_str in image:
296
+ img_path = Path(img_path_str)
297
+ if not img_path.exists():
298
+ typer.echo(f"Error: image file not found: {img_path_str}", err=True)
299
+ raise typer.Exit(1)
300
+ if img_path.stat().st_size > 20 * 1024 * 1024:
301
+ typer.echo(f"Error: image file too large (>20MB): {img_path_str}", err=True)
302
+ raise typer.Exit(1)
303
+ media_type = mimetypes.guess_type(img_path_str)[0] or "image/png"
304
+ if not media_type.startswith("image/"):
305
+ typer.echo(f"Error: not an image file: {img_path_str}", err=True)
306
+ raise typer.Exit(1)
307
+ with open(img_path, "rb") as f:
308
+ data = base64.b64encode(f.read()).decode("ascii")
309
+ image_attachments.append({"media_type": media_type, "data": data})
310
+
311
+ stdin_text = ""
312
+ if not sys.stdin.isatty():
313
+ stdin_text = sys.stdin.read().strip()
314
+
315
+ if pipe or stdin_text:
316
+ text = prompt or stdin_text
317
+ if not text:
318
+ typer.echo("Error: no prompt provided", err=True)
319
+ raise typer.Exit(1)
320
+
321
+ from crabcode_cli.pipe import run_pipe
322
+ asyncio.run(run_pipe(text, settings=settings, cwd=work_dir, images=image_attachments))
323
+ return
324
+
325
+ if prompt:
326
+ from crabcode_cli.pipe import run_pipe
327
+ asyncio.run(run_pipe(prompt, settings=settings, cwd=work_dir, images=image_attachments))
328
+ return
329
+
330
+ resume_id: str | None = None
331
+ if resume:
332
+ resume_id = resume
333
+ elif continue_last:
334
+ from crabcode_core.session.storage import SessionStorage
335
+ sessions = SessionStorage.list_sessions(work_dir)
336
+ if sessions:
337
+ resume_id = sessions[0]["session_id"]
338
+ else:
339
+ typer.echo("No previous sessions found.", err=True)
340
+
341
+ if file_settings.extra_tools:
342
+ try:
343
+ from crabcode_search.background import (
344
+ is_codebase_search_enabled,
345
+ maybe_spawn_background_indexer,
346
+ )
347
+
348
+ if is_codebase_search_enabled(file_settings.extra_tools):
349
+ maybe_spawn_background_indexer(
350
+ cwd=work_dir,
351
+ tool_config=file_settings.tool_settings.get("CodebaseSearch", {}),
352
+ )
353
+ except Exception:
354
+ logger.exception("Failed to start background indexer bootstrap")
355
+
356
+ from crabcode_cli.repl import run_repl
357
+ asyncio.run(run_repl(settings=settings, cwd=work_dir, resume_session_id=resume_id))
358
+ # Native library threads (PyTorch, FAISS) may keep the process alive
359
+ # after the asyncio loop shuts down; force-exit to avoid hanging.
360
+ os._exit(0)
361
+
362
+
363
+ sessions_app = typer.Typer(
364
+ name="sessions",
365
+ help="Manage CrabCode sessions",
366
+ )
367
+ app.add_typer(sessions_app, name="sessions")
368
+
369
+
370
+ @sessions_app.command("list")
371
+ def sessions_list(
372
+ all_projects: bool = typer.Option(False, "--all", "-a", help="List sessions across all projects"),
373
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
374
+ limit: int = typer.Option(20, "--limit", "-n", help="Max number of sessions to show"),
375
+ ) -> None:
376
+ """List recent sessions."""
377
+ work_dir = cwd or os.getcwd()
378
+ if all_projects:
379
+ from crabcode_core.session.meta_db import SessionMetaStore
380
+ store = SessionMetaStore()
381
+ try:
382
+ rows = store.list_recent(limit=limit)
383
+ finally:
384
+ store.close()
385
+ else:
386
+ from crabcode_core.session.storage import SessionStorage
387
+ rows = SessionStorage.list_sessions(work_dir)[:limit]
388
+ rows = [
389
+ {"id": r["session_id"], "cwd": work_dir, **{k: v for k, v in r.items() if k != "session_id"}}
390
+ for r in rows
391
+ ]
392
+
393
+ if not rows:
394
+ typer.echo("No sessions found.")
395
+ return
396
+
397
+ for i, r in enumerate(rows, 1):
398
+ sid = r.get("id", "")[:8]
399
+ cwd_col = r.get("cwd", "")
400
+ if len(cwd_col) > 30:
401
+ cwd_col = "…" + cwd_col[-29:]
402
+ model = r.get("model", "") or ""
403
+ tokens = r.get("tokens_used", 0)
404
+ tokens_str = f"{tokens // 1000}k" if tokens >= 1000 else str(tokens)
405
+ preview = r.get("title", "") or r.get("first_user_message", "") or r.get("preview", "")
406
+ project_part = f" [{cwd_col}]" if all_projects else ""
407
+ typer.echo(f" {i:>3}. {sid} {model[:16]:<16} {tokens_str:>6} tok{project_part} {preview[:50]}")
408
+
409
+
410
+ @sessions_app.command("search")
411
+ def sessions_search(
412
+ query: str = typer.Argument(..., help="Search query"),
413
+ limit: int = typer.Option(20, "--limit", "-n", help="Max results"),
414
+ ) -> None:
415
+ """Search sessions by title or first message."""
416
+ from crabcode_core.session.storage import SessionStorage
417
+ results = SessionStorage.search_sessions(query, limit=limit)
418
+ if not results:
419
+ typer.echo(f"No sessions matching \"{query}\".")
420
+ return
421
+ for i, r in enumerate(results, 1):
422
+ sid = r.get("id", "")[:8]
423
+ cwd_col = r.get("cwd", "")
424
+ if len(cwd_col) > 30:
425
+ cwd_col = "…" + cwd_col[-29:]
426
+ model = r.get("model", "") or ""
427
+ tokens = r.get("tokens_used", 0)
428
+ tokens_str = f"{tokens // 1000}k" if tokens >= 1000 else str(tokens)
429
+ preview = r.get("title", "") or r.get("first_user_message", "")
430
+ typer.echo(f" {i:>3}. {sid} {model[:16]:<16} {tokens_str:>6} tok [{cwd_col}] {preview[:50]}")
431
+
432
+
433
+ @sessions_app.command("export")
434
+ def sessions_export(
435
+ session_id: str = typer.Argument(..., help="Session ID (full or prefix)"),
436
+ fmt: str = typer.Option("md", "--format", "-f", help="Export format: md or json"),
437
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file path"),
438
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
439
+ ) -> None:
440
+ """Export a session transcript to Markdown or JSON."""
441
+ work_dir = cwd or os.getcwd()
442
+ from crabcode_core.session.export import export_json, export_markdown
443
+
444
+ try:
445
+ resolved_id, resolved_cwd = _resolve_export_session(session_id, work_dir)
446
+ except ValueError as exc:
447
+ typer.echo(f"Error: {exc}", err=True)
448
+ raise typer.Exit(1) from exc
449
+ if fmt not in {"md", "json"}:
450
+ typer.echo("Error: --format must be 'md' or 'json'", err=True)
451
+ raise typer.Exit(1)
452
+ if fmt == "json":
453
+ content = export_json(resolved_id, resolved_cwd)
454
+ ext = ".json"
455
+ else:
456
+ content = export_markdown(resolved_id, resolved_cwd)
457
+ ext = ".md"
458
+ out_path = output or os.path.join(work_dir, f"{resolved_id[:8]}{ext}")
459
+ with open(out_path, "w", encoding="utf-8") as f:
460
+ f.write(content)
461
+ typer.echo(f"Exported to {out_path}")
462
+
463
+
464
+ @sessions_app.command("fork")
465
+ def sessions_fork(
466
+ session_id: str = typer.Argument(..., help="Source session ID (full or prefix)"),
467
+ message_uuid: str = typer.Argument(..., help="Assistant message UUID (full or unique prefix)"),
468
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory used for local session lookup"),
469
+ title: Optional[str] = typer.Option(None, "--title", help="Title for the forked session"),
470
+ ) -> None:
471
+ """Fork a session at an assistant reply."""
472
+ work_dir = cwd or os.getcwd()
473
+ from crabcode_core.session.storage import SessionStorage
474
+
475
+ try:
476
+ resolved_id, resolved_cwd = _resolve_export_session(session_id, work_dir)
477
+ source = SessionStorage(resolved_cwd, resolved_id)
478
+ messages = source.load_messages()
479
+ selector = str(message_uuid or "").strip()
480
+ if not selector:
481
+ raise ValueError("assistant message UUID is required")
482
+ assistant_ids = [
483
+ str(message.get("uuid") or "")
484
+ for message in messages
485
+ if message.get("type") == "assistant" and message.get("uuid")
486
+ ]
487
+ matches = [value for value in assistant_ids if value == selector or value.startswith(selector)]
488
+ if len(matches) != 1:
489
+ if not matches:
490
+ raise ValueError(f"assistant message not found: {selector}")
491
+ raise ValueError(f"assistant message selector is ambiguous: {selector}")
492
+ forked = SessionStorage.fork_from(
493
+ resolved_cwd,
494
+ resolved_id,
495
+ matches[0],
496
+ title=title,
497
+ )
498
+ except (OSError, ValueError) as exc:
499
+ typer.echo(f"Error: {exc}", err=True)
500
+ raise typer.Exit(1) from exc
501
+
502
+ typer.echo(
503
+ f"Forked session {resolved_id[:8]}… at {matches[0][:8]}… "
504
+ f"into {forked.session_id}"
505
+ )
506
+
507
+
508
+ def _resolve_export_session(selector: str, cwd: str) -> tuple[str, str]:
509
+ """Resolve an export selector without silently producing an empty file."""
510
+ from crabcode_core.session.meta_db import SessionMetaStore
511
+ from crabcode_core.session.storage import SessionStorage, get_transcript_path
512
+
513
+ value = str(selector or "").strip()
514
+ if not value:
515
+ raise ValueError("session ID is required")
516
+
517
+ work_dir = os.path.abspath(cwd)
518
+ local_ids = [
519
+ str(row.get("session_id") or "")
520
+ for row in SessionStorage.list_sessions(work_dir)
521
+ if row.get("session_id")
522
+ ]
523
+ if value in local_ids:
524
+ return value, work_dir
525
+ local_matches = [session_id for session_id in local_ids if session_id.startswith(value)]
526
+ if len(local_matches) == 1:
527
+ return local_matches[0], work_dir
528
+ if len(local_matches) > 1:
529
+ raise ValueError(f"session selector is ambiguous: {value}")
530
+
531
+ store = SessionMetaStore()
532
+ try:
533
+ exact = store.get(value)
534
+ if exact is not None:
535
+ return value, str(exact.get("cwd") or work_dir)
536
+ matches = store.find_active_by_prefix(value, limit=2)
537
+ finally:
538
+ store.close()
539
+ if len(matches) == 1:
540
+ return str(matches[0]["id"]), str(matches[0].get("cwd") or work_dir)
541
+ if len(matches) > 1:
542
+ raise ValueError(f"session selector is ambiguous: {value}")
543
+
544
+ try:
545
+ if get_transcript_path(work_dir, value).exists():
546
+ return value, work_dir
547
+ except (TypeError, ValueError):
548
+ pass
549
+ raise ValueError(f"session not found: {value}")
550
+
551
+
552
+ @sessions_app.command("prune")
553
+ def sessions_prune(
554
+ days: int = typer.Option(30, "--days", "-d", help="Archive sessions older than N days"),
555
+ delete_files: bool = typer.Option(False, "--delete-files", help="Also delete JSONL transcript files"),
556
+ ) -> None:
557
+ """Archive old sessions and optionally delete their files."""
558
+ from crabcode_core.session.meta_db import SessionMetaStore
559
+ store = SessionMetaStore()
560
+ try:
561
+ archived = store.auto_archive(days=days)
562
+ typer.echo(f"Archived {archived} session(s) older than {days} days.")
563
+ if delete_files:
564
+ candidates = store.purge_archived(delete_rows=False)
565
+ purged = 0
566
+ failed: list[str] = []
567
+ for entry in candidates:
568
+ sid = entry["id"]
569
+ cwd = entry.get("cwd", "")
570
+ try:
571
+ if cwd:
572
+ from crabcode_core.session.storage import purge_session_artifacts
573
+
574
+ purge_session_artifacts(cwd, sid)
575
+ store.delete(sid)
576
+ purged += 1
577
+ except (OSError, ValueError):
578
+ # Retain the archived row so a later prune can retry and so
579
+ # the command never claims a failed disk deletion succeeded.
580
+ failed.append(sid)
581
+ typer.echo(f"Purged {purged} archived session(s) from database and disk.")
582
+ if failed:
583
+ typer.echo(
584
+ f"Failed to delete {len(failed)} session artifact set(s); "
585
+ "their archived database rows were retained for retry.",
586
+ err=True,
587
+ )
588
+ finally:
589
+ store.close()
590
+
591
+
592
+ @app.command("gateway")
593
+ def gateway(
594
+ port: int = typer.Option(4096, "--port", "-p", help="HTTP port"),
595
+ grpc_port: Optional[int] = typer.Option(None, "--grpc-port", help="gRPC port (optional)"),
596
+ host: str = typer.Option("127.0.0.1", "--host", help="Bind address"),
597
+ password: Optional[str] = typer.Option(None, "--password", help="Gateway password"),
598
+ security_mode: Optional[str] = typer.Option(None, "--security-mode", help="none, password, publickey, or mixed"),
599
+ password_hash: Optional[str] = typer.Option(None, "--password-hash", help="PBKDF2 password hash"),
600
+ authorized_keys: Optional[str] = typer.Option(None, "--authorized-keys", help="OpenSSH authorized_keys path"),
601
+ jwt_secret: Optional[str] = typer.Option(None, "--jwt-secret", help="JWT signing secret"),
602
+ cors: Optional[str] = typer.Option(None, "--cors", help="Allowed CORS origin"),
603
+ log_level: str = typer.Option("info", "--log-level", help="Log level"),
604
+ ) -> None:
605
+ """Start the CrabCode HTTP/gRPC gateway server."""
606
+ if os.name == "nt":
607
+ from crabcode_core._windows_process import attach_process_tree_job
608
+
609
+ # The handle stays open until Gateway exits, including a forced kill
610
+ # by Desktop/VS Code. All subsequently spawned descendants are owned.
611
+ attach_process_tree_job()
612
+ from crabcode_core.logging_utils import configure_logging
613
+ from crabcode_core.types.config import LoggingSettings
614
+
615
+ log_settings = LoggingSettings(level=log_level.upper())
616
+ configure_logging(os.getcwd(), log_settings)
617
+
618
+ cors_origins = [cors] if cors else None
619
+
620
+ from crabcode_gateway.server import run_server
621
+
622
+ from crabcode_core.config.manager import ConfigManager
623
+ overrides = {
624
+ key: value
625
+ for key, value in {
626
+ "mode": security_mode,
627
+ "password": password,
628
+ "password_hash": password_hash,
629
+ "authorized_keys": authorized_keys,
630
+ "jwt_secret": jwt_secret,
631
+ }.items()
632
+ if value is not None
633
+ }
634
+ if password is not None and security_mode is None:
635
+ overrides["mode"] = "password"
636
+ configured = ConfigManager(cwd=os.getcwd()).load_gateway_security(overrides)
637
+
638
+ run_server(
639
+ host=host,
640
+ port=port,
641
+ grpc_port=grpc_port,
642
+ password=configured.password,
643
+ security_mode=configured.mode,
644
+ password_hash=configured.password_hash,
645
+ authorized_keys_path=configured.authorized_keys,
646
+ jwt_secret=configured.jwt_secret,
647
+ token_ttl_seconds=configured.token_ttl_seconds,
648
+ cors_origins=cors_origins,
649
+ log_level=log_level,
650
+ )
651
+
652
+
653
+ @app.command("acp")
654
+ def acp_cmd(
655
+ port: int = typer.Option(4096, "--port", "-p", help="Internal HTTP server port"),
656
+ host: str = typer.Option("127.0.0.1", "--host", help="Internal HTTP server host"),
657
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
658
+ log_level: str = typer.Option("warning", "--log-level", help="Log level"),
659
+ ) -> None:
660
+ """Start ACP (Agent Client Protocol) server on stdio.
661
+
662
+ This command starts an internal Gateway HTTP server and then
663
+ runs the ACP agent, communicating with the editor over
664
+ stdin/stdout via JSON-RPC (ndjson).
665
+
666
+ Used by ACP-compatible editors (Zed, JetBrains, Neovim) to
667
+ integrate CrabCode as a coding agent backend.
668
+
669
+ Example Zed settings.json::
670
+
671
+ {
672
+ "agent": {
673
+ "profiles": {
674
+ "crabcode": {
675
+ "command": "crabcode",
676
+ "args": ["acp"]
677
+ }
678
+ }
679
+ }
680
+ }
681
+ """
682
+ import asyncio
683
+
684
+ from crabcode_core.logging_utils import configure_logging
685
+ from crabcode_core.types.config import LoggingSettings
686
+
687
+ work_dir = cwd or os.getcwd()
688
+ log_settings = LoggingSettings(level=log_level.upper())
689
+ configure_logging(work_dir, log_settings)
690
+
691
+ async def _run() -> None:
692
+ # 1. Start internal Gateway HTTP server
693
+ from crabcode_gateway.server import GatewayServer
694
+
695
+ server = GatewayServer(host=host, port=port, log_level=log_level)
696
+ await server.start_background()
697
+
698
+ # Wait for server to be ready
699
+ import httpx
700
+ async with httpx.AsyncClient() as health_client:
701
+ for _ in range(20):
702
+ try:
703
+ resp = await health_client.get(f"http://{host}:{port}/health")
704
+ if resp.status_code == 200:
705
+ break
706
+ except Exception:
707
+ pass
708
+ await asyncio.sleep(0.25)
709
+
710
+ # 2. Run ACP agent on stdio
711
+ from crabcode_gateway.acp.transport import run_acp_server
712
+ from crabcode_gateway.acp.types import ACPConfig
713
+
714
+ config = ACPConfig(base_url=f"http://{host}:{port}")
715
+ try:
716
+ await run_acp_server(config)
717
+ finally:
718
+ await server.stop()
719
+
720
+ asyncio.run(_run())
721
+ os._exit(0)
722
+
723
+
724
+ @app.command("stats")
725
+ def stats(
726
+ project: bool = typer.Option(False, "--project", "-p", help="Show only current project stats"),
727
+ cwd: Optional[str] = typer.Option(None, "--cwd", help="Working directory"),
728
+ ) -> None:
729
+ """Show usage statistics."""
730
+ work_dir = os.path.abspath(cwd or os.getcwd())
731
+ from crabcode_core.session.meta_db import SessionMetaStore
732
+ store = SessionMetaStore()
733
+
734
+ def _fmt_tok(n: int) -> str:
735
+ if n >= 1_000_000:
736
+ return f"{n / 1_000_000:.1f}M"
737
+ if n >= 1_000:
738
+ return f"{n / 1_000:.1f}k"
739
+ return str(n)
740
+
741
+ try:
742
+ if project:
743
+ p = store.stats_by_project(work_dir)
744
+ typer.echo(f"Project: {work_dir}")
745
+ typer.echo(f" Sessions: {p['total_sessions']} | Tokens: {_fmt_tok(p['total_tokens'])} | Messages: {p['total_messages']}")
746
+ else:
747
+ g = store.stats_global()
748
+ p = store.stats_by_project(work_dir)
749
+ models = store.stats_by_model(limit=5)
750
+ typer.echo(f"Global: {g['total_sessions']} sessions | {_fmt_tok(g['total_tokens'])} tokens | {g['active_projects']} projects")
751
+ typer.echo(f"This week: {g['week_sessions']} sessions | {_fmt_tok(g['week_tokens'])} tokens")
752
+ typer.echo(f"This project: {p['total_sessions']} sessions | {_fmt_tok(p['total_tokens'])} tokens | {p['total_messages']} messages")
753
+ if models:
754
+ model_parts = [f"{m['model']} ({_fmt_tok(m['tokens'])})" for m in models]
755
+ typer.echo(f"Top models: {', '.join(model_parts)}")
756
+ finally:
757
+ store.close()
758
+
759
+
760
+ def entry() -> None:
761
+ _configure_utf8_stdio()
762
+ known_subcommands = {"main", "sessions", "stats", "gateway", "acp", "document-engine", "computer-use"}
763
+ args = sys.argv[1:]
764
+ # Preserve root --help so users can still discover all subcommands.
765
+ if args and args[0] in ("--help", "-h"):
766
+ app()
767
+ return
768
+ # If the first positional argument is not a known subcommand, default to main.
769
+ first_positional = next((a for a in args if not a.startswith("-")), None)
770
+ if first_positional not in known_subcommands:
771
+ sys.argv.insert(1, "main")
772
+ app()
773
+
774
+
775
+ if __name__ == "__main__":
776
+ entry()