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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- 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,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()
|