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
@@ -0,0 +1,38 @@
1
+ """Resolve the implicit target against the connected executor, not the Gateway OS."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def effective_computer_use_mode(session: Any) -> str:
7
+ """Adapt only the omitted default; explicit targets and permissions never change."""
8
+ mode = getattr(session, "computer_use_mode", "background_app")
9
+ if mode not in ("background_app", "foreground_desktop"):
10
+ mode = "background_app"
11
+ configured = getattr(getattr(session, "settings", None), "computer_use", None)
12
+ if (
13
+ mode != "background_app"
14
+ or configured is None
15
+ or getattr(session, "_computer_use_mode_override", None) is not None
16
+ or "target_scope" in getattr(configured, "model_fields_set", {"target_scope"})
17
+ or getattr(
18
+ session, "effective_computer_use_delivery_policy",
19
+ getattr(session, "computer_use_delivery_policy", "allow_foreground"),
20
+ ) != "allow_foreground"
21
+ ):
22
+ return mode
23
+ backend = getattr(session, "computer_use_backend", None)
24
+ capabilities = getattr(backend, "capabilities", None)
25
+ caps = (
26
+ capabilities(getattr(session, "computer_use_host_id", None))
27
+ if callable(capabilities) else {}
28
+ )
29
+ modes = caps.get("supported_modes") if isinstance(caps, dict) else None
30
+ if (
31
+ isinstance(caps, dict)
32
+ and caps.get("platform") in ("windows", "win32")
33
+ and isinstance(modes, list)
34
+ and "foreground_desktop" in modes
35
+ and "background_app" not in modes
36
+ ):
37
+ return "foreground_desktop"
38
+ return mode
@@ -0,0 +1,144 @@
1
+ """Compact, indexed AX text for model requests; native receipts stay structured."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from difflib import unified_diff
8
+ from typing import Any
9
+
10
+
11
+ TREE_FORMAT = "indexed_text_v1"
12
+ _ELEMENT_ID = re.compile(r"e[0-9]+\Z")
13
+ _ROLES = {
14
+ "AXWindow": "window", "AXGroup": "container", "AXWebArea": "web content",
15
+ "AXStaticText": "text", "AXTextField": "text field", "AXTextArea": "text area",
16
+ "AXButton": "button", "AXImage": "image", "AXLink": "link",
17
+ "AXScrollArea": "scroll area", "AXCheckBox": "checkbox", "AXRadioButton": "radio button",
18
+ "AXPopUpButton": "pop up button", "AXComboBox": "combo box", "AXMenuItem": "menu item",
19
+ "AXCloseButton": "close button", "AXMinimizeButton": "minimize button",
20
+ "AXZoomButton": "zoom button", "AXFullScreenButton": "full screen button",
21
+ "AXStandardWindow": "window", "AXSecureTextField": "secure text field",
22
+ }
23
+
24
+
25
+ def _label(value: Any) -> str:
26
+ # Keep UI content on one quoted line, including text that resembles tree syntax.
27
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
28
+
29
+
30
+ def _node_text(node: dict[str, Any]) -> str:
31
+ role = str(node.get("role") or "element")
32
+ label_role = str(node.get("subrole") or role)
33
+ parts = [_ROLES.get(label_role, label_role.removeprefix("AX"))]
34
+ flags = []
35
+ allowed = node.get("allowed_actions") or []
36
+ if node.get("enabled") is False:
37
+ flags.append("disabled")
38
+ allowed = []
39
+ elif not node.get("protected"):
40
+ if "AXPress" in allowed or "AXPick" in allowed:
41
+ flags.append("press")
42
+ if node.get("allow_set_value"):
43
+ flags.append("settable")
44
+ if node.get("selected"):
45
+ flags.append("selected")
46
+ if node.get("focused"):
47
+ flags.append("focused")
48
+ if node.get("protected"):
49
+ flags.append("protected")
50
+ allowed = []
51
+ if flags:
52
+ parts.append(f"({', '.join(flags)})")
53
+ seen = []
54
+ for field in ("title", "description", "value"):
55
+ value = node.get(field)
56
+ if node.get("protected") and field != "title":
57
+ continue
58
+ if value is None or value == "" or value in seen:
59
+ continue
60
+ seen.append(value)
61
+ parts.append(("Value: " if field == "value" and len(seen) > 1 else "") + _label(value))
62
+ secondary = [a for a in allowed if a not in ("AXPress", "AXPick") and not (
63
+ role in ("AXStaticText", "AXImage", "AXGroup") and a in ("AXShowMenu", "AXScrollToVisible")
64
+ )]
65
+ if secondary:
66
+ parts.append("Secondary Actions: " + ", ".join(secondary))
67
+ return " ".join(parts)
68
+
69
+
70
+ def compact_ax_result(result: dict[str, Any]) -> dict[str, Any]:
71
+ """Return a model-only projection, without mutating the host data or images."""
72
+ accessibility = result.get("accessibility")
73
+ if not isinstance(accessibility, dict) or not isinstance(accessibility.get("elements"), list):
74
+ return result
75
+ nodes = {
76
+ node["element_id"]: node for node in accessibility["elements"]
77
+ if isinstance(node, dict) and isinstance(node.get("element_id"), str)
78
+ and _ELEMENT_ID.fullmatch(node["element_id"])
79
+ }
80
+ children: dict[str | None, list[str]] = {}
81
+ for element_id, node in nodes.items():
82
+ parent = node.get("parent_id")
83
+ if parent not in nodes or parent == element_id:
84
+ parent = None
85
+ children.setdefault(parent, []).append(element_id)
86
+ merged_values: dict[str, str] = {}
87
+ merged_away: set[str] = set()
88
+ # Rich text often exposes one AXStaticText per styled span. Join adjacent
89
+ # passive leaves under the same parent, never controls or protected values.
90
+ # The representative ID is still a real native node; it advertises no input.
91
+ for siblings in children.values():
92
+ run: list[str] = []
93
+ for element_id in [*siblings, None]:
94
+ node = nodes.get(element_id, {})
95
+ plain_text = (node.get("role") == "AXStaticText" and isinstance(node.get("value"), str)
96
+ and not node.get("title") and not node.get("description")
97
+ and not children.get(element_id) and node.get("enabled") is not False
98
+ and not any(node.get(flag) for flag in ("protected", "focused", "selected", "allow_set_value"))
99
+ and set(node.get("allowed_actions") or []) <= {"AXShowMenu", "AXScrollToVisible"})
100
+ if plain_text:
101
+ run.append(element_id)
102
+ continue
103
+ if len(run) > 1:
104
+ merged_values[run[0]] = " ".join(nodes[key]["value"] for key in run)
105
+ merged_away.update(run[1:])
106
+ run = []
107
+ lines = []
108
+ visited = set()
109
+ # DFS makes document text readable even when an older host returns BFS JSON.
110
+ pending = [(root, 0) for root in reversed(children.get(None, []))]
111
+ # Appending disconnected nodes also tolerates malformed cyclic parents.
112
+ pending = [(element_id, 0) for element_id in reversed(nodes)] + pending
113
+ while pending:
114
+ element_id, depth = pending.pop()
115
+ if element_id in visited:
116
+ continue
117
+ visited.add(element_id)
118
+ if element_id in merged_away:
119
+ continue
120
+ node = nodes[element_id]
121
+ if element_id in merged_values:
122
+ node = {**node, "value": merged_values[element_id]}
123
+ lines.append("\t" * min(depth, 64) + element_id + " " + _node_text(node))
124
+ pending.extend((child, depth + 1) for child in reversed(children.get(element_id, [])))
125
+ tree = {k: v for k, v in accessibility.items() if k not in ("elements", "coordinate_space")}
126
+ tree.update(tree_format=TREE_FORMAT, element_count=len(lines), source_element_count=len(nodes), tree="\n".join(lines))
127
+ return {**result, "accessibility": tree}
128
+
129
+
130
+ def diff_ax_tree(previous: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
131
+ """Use a delta only when it is smaller and its full base remains in context."""
132
+ if (previous.get("tree_format") != TREE_FORMAT or current.get("tree_format") != TREE_FORMAT
133
+ or not previous.get("snapshot_id") or not current.get("snapshot_id")
134
+ or not isinstance(previous.get("tree"), str) or not isinstance(current.get("tree"), str)):
135
+ return current
136
+ # Standard hunks preserve parent indentation AND sibling reading order,
137
+ # including inserted/removed/reordered elements whose IDs may be renumbered.
138
+ changes = list(unified_diff(previous["tree"].splitlines(), current["tree"].splitlines(), n=1, lineterm=""))[2:]
139
+ delta = {
140
+ **{k: v for k, v in current.items() if k != "tree"},
141
+ "base_snapshot_id": previous["snapshot_id"],
142
+ "tree_delta": "\n".join(changes) if changes else "No change in the accessibility tree.",
143
+ }
144
+ return delta if len(json.dumps(delta, ensure_ascii=False)) < len(json.dumps(current, ensure_ascii=False)) else current
@@ -0,0 +1,3 @@
1
+ from crabcode_core.config.manager import ConfigManager
2
+
3
+ __all__ = ["ConfigManager"]
@@ -0,0 +1,61 @@
1
+ """CLAUDE.md discovery — finds and loads CLAUDE.md files from the project hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from crabcode_core.logging_utils import get_logger
9
+ from crabcode_core.paths import get_config_home
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ def discover_claude_md(cwd: str) -> list[dict[str, str]]:
15
+ """Discover all CLAUDE.md files in the project hierarchy.
16
+
17
+ Returns list of dicts with 'path' and 'content' keys,
18
+ ordered from most general (home) to most specific (project root).
19
+ """
20
+ results: list[dict[str, str]] = []
21
+ home = Path.home()
22
+
23
+ global_files = [home / ".claude" / "CLAUDE.md", get_config_home() / "CLAUDE.md"]
24
+ for home_md in global_files:
25
+ if home_md.exists():
26
+ try:
27
+ content = home_md.read_text(encoding="utf-8", errors="replace")
28
+ results.append({"path": str(home_md), "content": content})
29
+ except Exception:
30
+ logger.warning("Failed to read %s", home_md, exc_info=True)
31
+
32
+ search_dir = Path(cwd).resolve()
33
+ project_files: list[dict[str, str]] = []
34
+
35
+ current = search_dir
36
+ while current != current.parent:
37
+ for name in ["CLAUDE.md", ".claude/CLAUDE.md", ".crabcode/CLAUDE.md"]:
38
+ candidate = current / name
39
+ # User-global files are handled above, including a relocated home.
40
+ if candidate in global_files or (current == home and name != "CLAUDE.md"):
41
+ continue
42
+ if candidate.exists():
43
+ try:
44
+ content = candidate.read_text(encoding="utf-8", errors="replace")
45
+ project_files.append({"path": str(candidate), "content": content})
46
+ except Exception:
47
+ logger.warning("Failed to read %s", candidate, exc_info=True)
48
+ if (current / ".git").exists():
49
+ break
50
+ current = current.parent
51
+
52
+ results.extend(reversed(project_files))
53
+ return results
54
+
55
+
56
+ def load_claude_md_as_string(cwd: str) -> str | None:
57
+ """Load all CLAUDE.md files and join them into a single string."""
58
+ files = discover_claude_md(cwd)
59
+ if not files:
60
+ return None
61
+ return "\n\n---\n\n".join(f["content"] for f in files)
@@ -0,0 +1,237 @@
1
+ """Configuration manager — 5-layer settings merge with Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from crabcode_core.logging_utils import get_logger
11
+ from crabcode_core.paths import get_config_home
12
+ from crabcode_core.text_io import read_utf8_text, write_utf8_text
13
+ from crabcode_core.types.config import (
14
+ ComputerUseSettings,
15
+ CrabCodeSettings,
16
+ GatewaySecuritySettings,
17
+ GatewayWorkspaceSettings,
18
+ )
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ SETTING_SOURCES = [
24
+ "userSettings",
25
+ "projectSettings",
26
+ "localSettings",
27
+ "flagSettings",
28
+ "policySettings",
29
+ ]
30
+
31
+
32
+ def _merge_settings(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
33
+ """Deep merge two settings dicts. Arrays are concatenated and deduped."""
34
+ result = deepcopy(base)
35
+ for key, value in override.items():
36
+ # Normalize each layer before merging: a higher-priority legacy mode
37
+ # must override a lower-priority target_scope without granting input.
38
+ if (
39
+ key == "computer_use"
40
+ and isinstance(value, dict)
41
+ and value.get("mode") in ("background_app", "foreground_desktop")
42
+ ):
43
+ value = ComputerUseSettings.migrate_mode(value)
44
+ if key in result:
45
+ if isinstance(result[key], list) and isinstance(value, list):
46
+ seen: set[str] = set()
47
+ merged: list[Any] = []
48
+ for item in result[key] + value:
49
+ item_key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else str(item)
50
+ if item_key not in seen:
51
+ seen.add(item_key)
52
+ merged.append(item)
53
+ result[key] = merged
54
+ elif isinstance(result[key], dict) and isinstance(value, dict):
55
+ result[key] = _merge_settings(result[key], value)
56
+ else:
57
+ result[key] = deepcopy(value)
58
+ else:
59
+ result[key] = deepcopy(value)
60
+ return result
61
+
62
+
63
+ class ConfigManager:
64
+ """Manages CrabCode settings from multiple sources.
65
+
66
+ Merge order (later overrides earlier):
67
+ userSettings -> projectSettings -> localSettings -> flagSettings -> policySettings
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ cwd: str = ".",
73
+ flag_settings_path: str | None = None,
74
+ ):
75
+ self._cwd = cwd
76
+ self._flag_settings_path = flag_settings_path
77
+ self._cache: CrabCodeSettings | None = None
78
+
79
+ @property
80
+ def settings_file_paths(self) -> dict[str, str | None]:
81
+ home = get_config_home()
82
+ project = Path(self._cwd).resolve()
83
+ return {
84
+ "userSettings": str(home / "settings.json"),
85
+ "projectSettings": str(project / ".crabcode" / "settings.json"),
86
+ "localSettings": str(project / ".crabcode" / "settings.local.json"),
87
+ "flagSettings": self._flag_settings_path,
88
+ "policySettings": str(home / "managed-settings.json"),
89
+ }
90
+
91
+ def load(self) -> CrabCodeSettings:
92
+ """Load and merge all settings layers."""
93
+ merged: dict[str, Any] = {}
94
+
95
+ for source in SETTING_SOURCES:
96
+ path_str = self.settings_file_paths.get(source)
97
+ if not path_str:
98
+ continue
99
+
100
+ path = Path(path_str)
101
+ if not path.exists():
102
+ continue
103
+
104
+ try:
105
+ raw = json.loads(read_utf8_text(path).text)
106
+ if isinstance(raw, dict):
107
+ merged = _merge_settings(merged, raw)
108
+ except (json.JSONDecodeError, OSError, UnicodeError):
109
+ logger.warning("Failed to load settings source: %s", path, exc_info=True)
110
+ continue
111
+
112
+ try:
113
+ self._cache = CrabCodeSettings.model_validate(merged)
114
+ except Exception:
115
+ logger.exception("Failed to validate merged settings; using defaults")
116
+ self._cache = CrabCodeSettings()
117
+
118
+ return self._cache
119
+
120
+ def get(self) -> CrabCodeSettings:
121
+ """Get cached settings or load from disk."""
122
+ if self._cache is None:
123
+ return self.load()
124
+ return self._cache
125
+
126
+ def reset_cache(self) -> None:
127
+ """Clear the cached settings."""
128
+ self._cache = None
129
+
130
+ def load_gateway_security(
131
+ self,
132
+ overrides: dict[str, Any] | None = None,
133
+ ) -> GatewaySecuritySettings:
134
+ """Load process-level gateway security without project overrides.
135
+
136
+ A repository must not be able to weaken authentication for a gateway
137
+ launched from that repository. CLI overrides sit between user and
138
+ managed policy settings, matching the normal precedence model.
139
+ """
140
+ merged: dict[str, Any] = {}
141
+ for source in ("userSettings",):
142
+ raw = self.get_settings_for_source(source) or {}
143
+ gateway = raw.get("gateway", {})
144
+ security = gateway.get("security", {}) if isinstance(gateway, dict) else {}
145
+ if isinstance(security, dict):
146
+ merged = _merge_settings(merged, security)
147
+ if overrides:
148
+ merged = _merge_settings(merged, overrides)
149
+ raw_policy = self.get_settings_for_source("policySettings") or {}
150
+ policy_gateway = raw_policy.get("gateway", {})
151
+ policy_security = (
152
+ policy_gateway.get("security", {})
153
+ if isinstance(policy_gateway, dict)
154
+ else {}
155
+ )
156
+ if isinstance(policy_security, dict):
157
+ merged = _merge_settings(merged, policy_security)
158
+ return GatewaySecuritySettings.model_validate(merged)
159
+
160
+ def load_gateway_workspace(self) -> GatewayWorkspaceSettings:
161
+ """Load filesystem discovery policy without project overrides."""
162
+ merged: dict[str, Any] = {}
163
+ raw_user = self.get_settings_for_source("userSettings") or {}
164
+ user_gateway = raw_user.get("gateway", {})
165
+ user_workspace = (
166
+ user_gateway.get("workspace", {})
167
+ if isinstance(user_gateway, dict)
168
+ else {}
169
+ )
170
+ if isinstance(user_workspace, dict):
171
+ merged = _merge_settings(merged, user_workspace)
172
+
173
+ raw_policy = self.get_settings_for_source("policySettings") or {}
174
+ policy_gateway = raw_policy.get("gateway", {})
175
+ policy_workspace = (
176
+ policy_gateway.get("workspace", {})
177
+ if isinstance(policy_gateway, dict)
178
+ else {}
179
+ )
180
+ if isinstance(policy_workspace, dict):
181
+ merged = _merge_settings(merged, policy_workspace)
182
+ return GatewayWorkspaceSettings.model_validate(merged)
183
+
184
+ def get_settings_for_source(self, source: str) -> dict[str, Any] | None:
185
+ """Get raw settings from a single source."""
186
+ path_str = self.settings_file_paths.get(source)
187
+ if not path_str:
188
+ return None
189
+
190
+ path = Path(path_str)
191
+ if not path.exists():
192
+ return None
193
+
194
+ try:
195
+ raw = json.loads(read_utf8_text(path).text)
196
+ return raw if isinstance(raw, dict) else None
197
+ except (json.JSONDecodeError, OSError, UnicodeError):
198
+ logger.warning("Failed to read settings source: %s", path, exc_info=True)
199
+ return None
200
+
201
+ def update_settings(
202
+ self,
203
+ source: str,
204
+ settings: dict[str, Any],
205
+ ) -> None:
206
+ """Update settings for a given source."""
207
+ if source in ("policySettings", "flagSettings"):
208
+ return
209
+
210
+ path_str = self.settings_file_paths.get(source)
211
+ if not path_str:
212
+ return
213
+
214
+ path = Path(path_str)
215
+ path.parent.mkdir(parents=True, exist_ok=True)
216
+
217
+ existing: dict[str, Any] = {}
218
+ newline = "\n"
219
+ has_bom = False
220
+ if path.exists():
221
+ try:
222
+ source = read_utf8_text(path)
223
+ existing = json.loads(source.text)
224
+ newline = source.newline or newline
225
+ has_bom = source.has_bom
226
+ except (json.JSONDecodeError, OSError, UnicodeError):
227
+ logger.warning("Failed to read existing settings before update: %s", path, exc_info=True)
228
+
229
+ merged = _merge_settings(existing, settings)
230
+ write_utf8_text(
231
+ path,
232
+ json.dumps(merged, indent=2, ensure_ascii=False) + "\n",
233
+ newline=newline,
234
+ has_bom=has_bom,
235
+ )
236
+
237
+ self.reset_cache()