anywhere-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anywhere_cli-0.1.0.dist-info/METADATA +110 -0
- anywhere_cli-0.1.0.dist-info/RECORD +36 -0
- anywhere_cli-0.1.0.dist-info/WHEEL +4 -0
- anywhere_cli-0.1.0.dist-info/entry_points.txt +3 -0
- connector/__init__.py +3 -0
- connector/adapter.py +39 -0
- connector/attachments.py +36 -0
- connector/capabilities.py +334 -0
- connector/claude/__init__.py +8 -0
- connector/claude/history_adapter.py +642 -0
- connector/claude/normalized.py +23 -0
- connector/claude/normalizers.py +97 -0
- connector/claude/path_utils.py +13 -0
- connector/claude/preferences.py +38 -0
- connector/claude/sdk_adapter.py +1377 -0
- connector/claude/timeline_identity.py +47 -0
- connector/claude/timeline_reducer.py +379 -0
- connector/claude/trust.py +69 -0
- connector/cli.py +149 -0
- connector/codex/__init__.py +3 -0
- connector/codex/adapter.py +951 -0
- connector/codex/history.py +199 -0
- connector/codex/reducer.py +1223 -0
- connector/codex/rpc.py +260 -0
- connector/launch.py +104 -0
- connector/local/__init__.py +6 -0
- connector/local/common.py +118 -0
- connector/local/file_ops.py +122 -0
- connector/local/ops.py +83 -0
- connector/local/shell.py +225 -0
- connector/local/terminal.py +389 -0
- connector/local_ops.py +5 -0
- connector/protocol.py +26 -0
- connector/runtime.py +1002 -0
- connector/sync_state.py +155 -0
- connector/time.py +7 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ClaudeTimelineIdentity:
|
|
9
|
+
@staticmethod
|
|
10
|
+
def message(*, session_id: str, claude_session_id: str, message_id: str) -> str:
|
|
11
|
+
return f"claude_msg_{_short('message', claude_session_id, message_id)}"
|
|
12
|
+
|
|
13
|
+
@staticmethod
|
|
14
|
+
def tool_call(
|
|
15
|
+
*,
|
|
16
|
+
session_id: str,
|
|
17
|
+
claude_session_id: str,
|
|
18
|
+
tool_use_id: str,
|
|
19
|
+
) -> str:
|
|
20
|
+
return f"claude_tool_{_short('tool', claude_session_id, tool_use_id)}"
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def tool_result(
|
|
24
|
+
*,
|
|
25
|
+
session_id: str,
|
|
26
|
+
claude_session_id: str,
|
|
27
|
+
tool_use_id: str,
|
|
28
|
+
) -> str:
|
|
29
|
+
return ClaudeTimelineIdentity.tool_call(
|
|
30
|
+
session_id=session_id,
|
|
31
|
+
claude_session_id=claude_session_id,
|
|
32
|
+
tool_use_id=tool_use_id,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
@staticmethod
|
|
36
|
+
def derived(*values: Any) -> str:
|
|
37
|
+
return f"claude_derived_{_short(*values)}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def content_hash(*values: Any) -> str:
|
|
41
|
+
payload = json.dumps(values, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
42
|
+
return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _short(*values: Any) -> str:
|
|
46
|
+
payload = json.dumps(values, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
47
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from connector.claude.normalized import NormalizedClaudeEvent
|
|
7
|
+
from connector.claude.timeline_identity import ClaudeTimelineIdentity, content_hash
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ClaudeTimelineReducer:
|
|
11
|
+
def reduce(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
session_id: str,
|
|
15
|
+
turn_id: str,
|
|
16
|
+
events: list[NormalizedClaudeEvent],
|
|
17
|
+
) -> list[dict[str, Any]]:
|
|
18
|
+
items: dict[str, dict[str, Any]] = {}
|
|
19
|
+
ignored_tool_use_ids: set[str] = set()
|
|
20
|
+
order_seq = 1
|
|
21
|
+
for event in events:
|
|
22
|
+
if event.toolUseId and event.toolResult is None and is_task_event_tool_name(event.toolName):
|
|
23
|
+
ignored_tool_use_ids.add(event.toolUseId)
|
|
24
|
+
continue
|
|
25
|
+
if event.toolUseId and event.toolResult is not None and event.toolUseId in ignored_tool_use_ids:
|
|
26
|
+
continue
|
|
27
|
+
item = self._reduce_event(session_id=session_id, turn_id=turn_id, event=event, order_seq=order_seq)
|
|
28
|
+
if item is None:
|
|
29
|
+
continue
|
|
30
|
+
existing = items.get(item["id"])
|
|
31
|
+
if existing is None:
|
|
32
|
+
items[item["id"]] = item
|
|
33
|
+
order_seq += 1
|
|
34
|
+
continue
|
|
35
|
+
items[item["id"]] = _merge_item(existing, item)
|
|
36
|
+
return sorted(items.values(), key=lambda item: int(item.get("orderSeq") or 0))
|
|
37
|
+
|
|
38
|
+
def _reduce_event(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
session_id: str,
|
|
42
|
+
turn_id: str,
|
|
43
|
+
event: NormalizedClaudeEvent,
|
|
44
|
+
order_seq: int,
|
|
45
|
+
) -> dict[str, Any] | None:
|
|
46
|
+
message_id = event.messageId or event.sourceEventId
|
|
47
|
+
if event.toolUseId and event.toolResult is not None:
|
|
48
|
+
item_id = ClaudeTimelineIdentity.tool_result(
|
|
49
|
+
session_id=session_id,
|
|
50
|
+
claude_session_id=event.claudeSessionId,
|
|
51
|
+
tool_use_id=event.toolUseId,
|
|
52
|
+
)
|
|
53
|
+
content = _tool_result_content(event)
|
|
54
|
+
status = "failed" if event.toolResultIsError else "done"
|
|
55
|
+
return {
|
|
56
|
+
"id": item_id,
|
|
57
|
+
"sessionId": session_id,
|
|
58
|
+
"turnId": turn_id,
|
|
59
|
+
"type": "tool",
|
|
60
|
+
"status": status,
|
|
61
|
+
"role": "tool",
|
|
62
|
+
"content": content,
|
|
63
|
+
"source": _source(event, turn_id, "tool_result"),
|
|
64
|
+
"orderSeq": order_seq,
|
|
65
|
+
"revision": 1,
|
|
66
|
+
"contentHash": content_hash(content),
|
|
67
|
+
"createdAt": event.timestamp,
|
|
68
|
+
"updatedAt": event.timestamp,
|
|
69
|
+
"completedAt": event.timestamp,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if event.toolUseId:
|
|
73
|
+
item_id = ClaudeTimelineIdentity.tool_call(
|
|
74
|
+
session_id=session_id,
|
|
75
|
+
claude_session_id=event.claudeSessionId,
|
|
76
|
+
tool_use_id=event.toolUseId,
|
|
77
|
+
)
|
|
78
|
+
content = _tool_call_content(event)
|
|
79
|
+
return {
|
|
80
|
+
"id": item_id,
|
|
81
|
+
"sessionId": session_id,
|
|
82
|
+
"turnId": turn_id,
|
|
83
|
+
"type": "tool",
|
|
84
|
+
"status": "running",
|
|
85
|
+
"role": "tool",
|
|
86
|
+
"content": content,
|
|
87
|
+
"source": _source(event, turn_id, "tool_use"),
|
|
88
|
+
"orderSeq": order_seq,
|
|
89
|
+
"revision": 1,
|
|
90
|
+
"contentHash": content_hash(content),
|
|
91
|
+
"createdAt": event.timestamp,
|
|
92
|
+
"updatedAt": event.timestamp,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if event.role in {"user", "assistant", "system"} and event.text is not None and event.text.strip():
|
|
96
|
+
item_id = ClaudeTimelineIdentity.message(
|
|
97
|
+
session_id=session_id,
|
|
98
|
+
claude_session_id=event.claudeSessionId,
|
|
99
|
+
message_id=message_id,
|
|
100
|
+
)
|
|
101
|
+
content = {"text": event.text}
|
|
102
|
+
if event.attachments:
|
|
103
|
+
content["attachments"] = event.attachments
|
|
104
|
+
source = _source(event, turn_id, "message")
|
|
105
|
+
if event.clientMessageId:
|
|
106
|
+
source["clientMessageId"] = event.clientMessageId
|
|
107
|
+
return {
|
|
108
|
+
"id": item_id,
|
|
109
|
+
"sessionId": session_id,
|
|
110
|
+
"turnId": turn_id,
|
|
111
|
+
"type": "message",
|
|
112
|
+
"status": "done",
|
|
113
|
+
"role": event.role,
|
|
114
|
+
"content": content,
|
|
115
|
+
"source": source,
|
|
116
|
+
"orderSeq": order_seq,
|
|
117
|
+
"revision": 1,
|
|
118
|
+
"contentHash": content_hash(content),
|
|
119
|
+
"createdAt": event.timestamp,
|
|
120
|
+
"updatedAt": event.timestamp,
|
|
121
|
+
"completedAt": event.timestamp,
|
|
122
|
+
}
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _source(event: NormalizedClaudeEvent, turn_id: str, derived_key: str) -> dict[str, Any]:
|
|
127
|
+
return {
|
|
128
|
+
"runtime": "claude",
|
|
129
|
+
"sessionId": event.claudeSessionId,
|
|
130
|
+
"turnId": turn_id,
|
|
131
|
+
"itemId": event.toolUseId or event.messageId or event.sourceEventId,
|
|
132
|
+
"itemType": event.blockType,
|
|
133
|
+
"event": event.sourceEventId,
|
|
134
|
+
"derivedKey": derived_key,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _tool_kind(tool_name: str | None) -> str:
|
|
139
|
+
if _mcp_parts(tool_name) is not None:
|
|
140
|
+
return "mcp"
|
|
141
|
+
if tool_name in {"Edit", "Write", "NotebookEdit"}:
|
|
142
|
+
return "file_change"
|
|
143
|
+
if tool_name == "MultiEdit":
|
|
144
|
+
return "file_change"
|
|
145
|
+
if tool_name == "Bash":
|
|
146
|
+
return "command"
|
|
147
|
+
if tool_name in {"WebFetch", "WebSearch"}:
|
|
148
|
+
return "web_search"
|
|
149
|
+
return "tool"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_task_event_tool_name(tool_name: str | None) -> bool:
|
|
153
|
+
"""Claude Code task bookkeeping tools should not become user-visible tools.
|
|
154
|
+
|
|
155
|
+
Keep the real Claude sub-agent tool named exactly "Task"; hide status/event
|
|
156
|
+
tools such as TaskCreate and TaskUpdate.
|
|
157
|
+
"""
|
|
158
|
+
return bool(tool_name and tool_name != "Task" and tool_name.startswith("Task") and len(tool_name) > 4 and tool_name[4].isupper())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _merge_item(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
if existing.get("type") == "tool" and incoming.get("status") in {"done", "failed", "interrupted", "cancelled"}:
|
|
163
|
+
data = dict(existing)
|
|
164
|
+
content = dict(data.get("content") or {})
|
|
165
|
+
incoming_content = incoming.get("content")
|
|
166
|
+
content.update(incoming_content if isinstance(incoming_content, dict) else {})
|
|
167
|
+
data["content"] = content
|
|
168
|
+
data["status"] = incoming.get("status")
|
|
169
|
+
data["role"] = incoming.get("role") or data.get("role")
|
|
170
|
+
data["revision"] = int(data.get("revision") or 1) + 1
|
|
171
|
+
data["contentHash"] = content_hash(content)
|
|
172
|
+
data["updatedAt"] = incoming.get("updatedAt")
|
|
173
|
+
data["completedAt"] = incoming.get("completedAt")
|
|
174
|
+
return data
|
|
175
|
+
if _content_score(incoming.get("content")) > _content_score(existing.get("content")):
|
|
176
|
+
return incoming
|
|
177
|
+
return existing
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _content_score(content: Any) -> int:
|
|
181
|
+
return len(str(content or ""))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _tool_call_content(event: NormalizedClaudeEvent) -> dict[str, Any]:
|
|
185
|
+
tool_name = event.toolName or "tool"
|
|
186
|
+
tool_input = event.toolInput if isinstance(event.toolInput, dict) else event.toolInput
|
|
187
|
+
common = _tool_common(event)
|
|
188
|
+
kind = _tool_kind(tool_name)
|
|
189
|
+
if kind == "command":
|
|
190
|
+
input_data = tool_input if isinstance(tool_input, dict) else {}
|
|
191
|
+
command = _string(input_data.get("command") or input_data.get("cmd")) or ""
|
|
192
|
+
content = {
|
|
193
|
+
**common,
|
|
194
|
+
"kind": "command",
|
|
195
|
+
"command": command,
|
|
196
|
+
"description": _string(input_data.get("description")) or command,
|
|
197
|
+
"cwd": _string(input_data.get("cwd")),
|
|
198
|
+
}
|
|
199
|
+
if isinstance(input_data.get("run_in_background"), bool):
|
|
200
|
+
content["runInBackground"] = input_data.get("run_in_background")
|
|
201
|
+
return _strip_empty(content)
|
|
202
|
+
|
|
203
|
+
if kind == "file_change":
|
|
204
|
+
return _strip_empty({
|
|
205
|
+
**common,
|
|
206
|
+
"kind": "file_change",
|
|
207
|
+
"changes": _file_changes(tool_name, tool_input),
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
if kind == "web_search":
|
|
211
|
+
input_data = tool_input if isinstance(tool_input, dict) else {}
|
|
212
|
+
return _strip_empty({
|
|
213
|
+
**common,
|
|
214
|
+
"kind": "web_search",
|
|
215
|
+
"query": _string(input_data.get("query")),
|
|
216
|
+
"url": _string(input_data.get("url")),
|
|
217
|
+
"action": input_data,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
mcp = _mcp_parts(tool_name)
|
|
221
|
+
if mcp is not None:
|
|
222
|
+
server, tool = mcp
|
|
223
|
+
return _strip_empty({
|
|
224
|
+
**common,
|
|
225
|
+
"kind": "mcp",
|
|
226
|
+
"server": server,
|
|
227
|
+
"tool": tool,
|
|
228
|
+
"arguments": tool_input if isinstance(tool_input, dict) else {},
|
|
229
|
+
"result": None,
|
|
230
|
+
"error": None,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
return _strip_empty({
|
|
234
|
+
**common,
|
|
235
|
+
"kind": "tool",
|
|
236
|
+
"name": tool_name,
|
|
237
|
+
"tool": tool_name,
|
|
238
|
+
"arguments": tool_input if isinstance(tool_input, dict) else tool_input,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _tool_result_content(event: NormalizedClaudeEvent) -> dict[str, Any]:
|
|
243
|
+
text = _result_text(event.toolResult)
|
|
244
|
+
content: dict[str, Any] = {
|
|
245
|
+
"toolUseId": event.toolUseId,
|
|
246
|
+
"result": event.toolResult,
|
|
247
|
+
"text": text,
|
|
248
|
+
"outputText": text,
|
|
249
|
+
"outputPreview": _preview_text(text),
|
|
250
|
+
"outputLength": len(text),
|
|
251
|
+
}
|
|
252
|
+
if event.toolResultIsError:
|
|
253
|
+
content["isError"] = True
|
|
254
|
+
content["error"] = text
|
|
255
|
+
return content
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _tool_common(event: NormalizedClaudeEvent) -> dict[str, Any]:
|
|
259
|
+
return {
|
|
260
|
+
"toolUseId": event.toolUseId,
|
|
261
|
+
"toolName": event.toolName,
|
|
262
|
+
"input": event.toolInput,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _file_changes(tool_name: str, tool_input: Any) -> list[dict[str, Any]]:
|
|
267
|
+
input_data = tool_input if isinstance(tool_input, dict) else {}
|
|
268
|
+
if tool_name == "Write":
|
|
269
|
+
path = _string(input_data.get("file_path") or input_data.get("path")) or ""
|
|
270
|
+
text = _string(input_data.get("content")) or ""
|
|
271
|
+
return [_strip_empty({
|
|
272
|
+
"path": path,
|
|
273
|
+
"action": "add",
|
|
274
|
+
"kind": {"type": "add"},
|
|
275
|
+
"diff": text,
|
|
276
|
+
})]
|
|
277
|
+
if tool_name == "Edit":
|
|
278
|
+
path = _string(input_data.get("file_path") or input_data.get("path")) or ""
|
|
279
|
+
return [_strip_empty({
|
|
280
|
+
"path": path,
|
|
281
|
+
"action": "update",
|
|
282
|
+
"kind": {"type": "update"},
|
|
283
|
+
"diff": _edit_diff(
|
|
284
|
+
path,
|
|
285
|
+
_string(input_data.get("old_string")) or "",
|
|
286
|
+
_string(input_data.get("new_string")) or "",
|
|
287
|
+
),
|
|
288
|
+
})]
|
|
289
|
+
if tool_name == "MultiEdit":
|
|
290
|
+
path = _string(input_data.get("file_path") or input_data.get("path")) or ""
|
|
291
|
+
edits = input_data.get("edits")
|
|
292
|
+
if not isinstance(edits, list):
|
|
293
|
+
edits = []
|
|
294
|
+
diff_parts = []
|
|
295
|
+
for edit in edits:
|
|
296
|
+
if not isinstance(edit, dict):
|
|
297
|
+
continue
|
|
298
|
+
diff_parts.append(
|
|
299
|
+
_edit_diff(
|
|
300
|
+
path,
|
|
301
|
+
_string(edit.get("old_string")) or "",
|
|
302
|
+
_string(edit.get("new_string")) or "",
|
|
303
|
+
include_header=not diff_parts,
|
|
304
|
+
)
|
|
305
|
+
)
|
|
306
|
+
return [_strip_empty({
|
|
307
|
+
"path": path,
|
|
308
|
+
"action": "update",
|
|
309
|
+
"kind": {"type": "update"},
|
|
310
|
+
"diff": "\n".join(part for part in diff_parts if part),
|
|
311
|
+
})]
|
|
312
|
+
if tool_name == "NotebookEdit":
|
|
313
|
+
path = _string(input_data.get("notebook_path") or input_data.get("file_path") or input_data.get("path")) or ""
|
|
314
|
+
new_source = _string(input_data.get("new_source")) or _json_text(input_data.get("new_source"))
|
|
315
|
+
return [_strip_empty({
|
|
316
|
+
"path": path,
|
|
317
|
+
"action": "update",
|
|
318
|
+
"kind": {"type": "update"},
|
|
319
|
+
"diff": _edit_diff(path, "", new_source),
|
|
320
|
+
})]
|
|
321
|
+
path = _string(input_data.get("file_path") or input_data.get("path")) or ""
|
|
322
|
+
return [_strip_empty({"path": path, "action": "update", "kind": {"type": "update"}})]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _edit_diff(path: str, old: str, new: str, *, include_header: bool = True) -> str:
|
|
326
|
+
lines: list[str] = []
|
|
327
|
+
if include_header:
|
|
328
|
+
lines.extend([f"--- {path}", f"+++ {path}"])
|
|
329
|
+
lines.append("@@")
|
|
330
|
+
if old:
|
|
331
|
+
lines.extend(f"-{line}" for line in old.splitlines())
|
|
332
|
+
if new:
|
|
333
|
+
lines.extend(f"+{line}" for line in new.splitlines())
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _mcp_parts(tool_name: str | None) -> tuple[str, str] | None:
|
|
338
|
+
if not tool_name or not tool_name.startswith("mcp__"):
|
|
339
|
+
return None
|
|
340
|
+
parts = tool_name.split("__", 2)
|
|
341
|
+
if len(parts) != 3 or not parts[1] or not parts[2]:
|
|
342
|
+
return None
|
|
343
|
+
return parts[1], parts[2]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _result_text(value: Any) -> str:
|
|
347
|
+
if isinstance(value, str):
|
|
348
|
+
return value
|
|
349
|
+
if isinstance(value, list):
|
|
350
|
+
texts: list[str] = []
|
|
351
|
+
for item in value:
|
|
352
|
+
if isinstance(item, dict) and isinstance(item.get("text"), str):
|
|
353
|
+
texts.append(item["text"])
|
|
354
|
+
elif isinstance(item, str):
|
|
355
|
+
texts.append(item)
|
|
356
|
+
if texts:
|
|
357
|
+
return "\n".join(texts)
|
|
358
|
+
if value is None:
|
|
359
|
+
return ""
|
|
360
|
+
return _json_text(value)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _json_text(value: Any) -> str:
|
|
364
|
+
try:
|
|
365
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
366
|
+
except TypeError:
|
|
367
|
+
return str(value)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _preview_text(value: str, limit: int = 4000) -> str:
|
|
371
|
+
return value[-limit:]
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _string(value: Any) -> str | None:
|
|
375
|
+
return value if isinstance(value, str) else None
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _strip_empty(value: dict[str, Any]) -> dict[str, Any]:
|
|
379
|
+
return {key: item for key, item in value.items() if item is not None}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Pre-accept the per-cwd Claude trust dialog.
|
|
2
|
+
|
|
3
|
+
First time you point `claude` at a brand-new directory, the TUI pops a
|
|
4
|
+
'Is this a project you trust?' prompt that blocks everything until you
|
|
5
|
+
say yes. The decision is persisted in `~/.claude.json` under
|
|
6
|
+
`projects[<cwd>].hasTrustDialogAccepted`.
|
|
7
|
+
|
|
8
|
+
Daemon-driven spawns shouldn't see that dialog at all. This module writes
|
|
9
|
+
the flag *before* spawn. Research doc §3.3: also write the resolved cwd
|
|
10
|
+
because macOS may resolve symlinks before the prompt logic runs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from loguru import logger
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _claude_config_path() -> Path:
|
|
22
|
+
return Path.home() / ".claude.json"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def ensure_trust(cwd: str | Path, *, config_path: Path | None = None) -> None:
|
|
26
|
+
"""Idempotently mark `cwd` (and its resolved form) as trusted."""
|
|
27
|
+
path = config_path or _claude_config_path()
|
|
28
|
+
candidates = _trust_candidates(cwd)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
|
|
32
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
33
|
+
logger.warning("trust: ~/.claude.json unreadable, starting fresh ({})", exc)
|
|
34
|
+
data = {}
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
data = {}
|
|
37
|
+
|
|
38
|
+
projects = data.get("projects")
|
|
39
|
+
if not isinstance(projects, dict):
|
|
40
|
+
projects = {}
|
|
41
|
+
data["projects"] = projects
|
|
42
|
+
|
|
43
|
+
changed = False
|
|
44
|
+
for candidate in candidates:
|
|
45
|
+
entry = projects.get(candidate)
|
|
46
|
+
if not isinstance(entry, dict):
|
|
47
|
+
entry = {}
|
|
48
|
+
projects[candidate] = entry
|
|
49
|
+
if not entry.get("hasTrustDialogAccepted"):
|
|
50
|
+
entry["hasTrustDialogAccepted"] = True
|
|
51
|
+
changed = True
|
|
52
|
+
|
|
53
|
+
if not changed:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _trust_candidates(cwd: str | Path) -> list[str]:
|
|
61
|
+
raw = str(cwd)
|
|
62
|
+
candidates = [raw]
|
|
63
|
+
try:
|
|
64
|
+
resolved = str(Path(cwd).resolve())
|
|
65
|
+
except OSError:
|
|
66
|
+
resolved = raw
|
|
67
|
+
if resolved != raw:
|
|
68
|
+
candidates.append(resolved)
|
|
69
|
+
return candidates
|
connector/cli.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from connector.runtime import BackendRpcClient, ConnectorConfig
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> None:
|
|
16
|
+
parser = _build_parser()
|
|
17
|
+
args = parser.parse_args(argv)
|
|
18
|
+
try:
|
|
19
|
+
if args.command == "login":
|
|
20
|
+
asyncio.run(_login(args))
|
|
21
|
+
elif args.command == "configure":
|
|
22
|
+
_configure(args)
|
|
23
|
+
elif args.command == "start":
|
|
24
|
+
asyncio.run(_start(args))
|
|
25
|
+
else:
|
|
26
|
+
parser.print_help()
|
|
27
|
+
except KeyboardInterrupt:
|
|
28
|
+
raise SystemExit(130) from None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = argparse.ArgumentParser(prog="anywhere-cli", description="Agent Server Codex connector CLI")
|
|
33
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
34
|
+
|
|
35
|
+
start = subparsers.add_parser("start", help="start the connector")
|
|
36
|
+
_add_config_args(start)
|
|
37
|
+
start.add_argument("--server-url", help="backend server URL")
|
|
38
|
+
start.add_argument("--connector-id", help="connector id")
|
|
39
|
+
start.add_argument("--connector-token", help="connector token")
|
|
40
|
+
|
|
41
|
+
login = subparsers.add_parser("login", help="pair with a backend, save credentials, and start the connector")
|
|
42
|
+
_add_config_args(login)
|
|
43
|
+
login.add_argument("--server-url", required=True, help="backend server URL")
|
|
44
|
+
login.add_argument("--poll-interval", type=float, default=2, help="seconds between pairing polls")
|
|
45
|
+
login.add_argument("--timeout", type=float, default=600, help="pairing timeout in seconds")
|
|
46
|
+
login.add_argument("--no-start", action="store_true", help="save credentials without starting the connector")
|
|
47
|
+
|
|
48
|
+
configure = subparsers.add_parser("configure", help="save connector credentials to local JSON")
|
|
49
|
+
_add_config_args(configure)
|
|
50
|
+
configure.add_argument("--server-url", required=True, help="backend server URL")
|
|
51
|
+
configure.add_argument("--connector-id", required=True, help="connector id")
|
|
52
|
+
configure.add_argument("--connector-token", required=True, help="connector token")
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _add_config_args(parser: argparse.ArgumentParser) -> None:
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--config",
|
|
59
|
+
default=str(ConnectorConfig.default_path()),
|
|
60
|
+
help="local connector config JSON path",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _start(args: argparse.Namespace) -> None:
|
|
65
|
+
config = _resolve_config(args)
|
|
66
|
+
await BackendRpcClient(config).run_forever()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _login(args: argparse.Namespace) -> None:
|
|
70
|
+
server_url = args.server_url.rstrip("/")
|
|
71
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
72
|
+
start_response = await client.post(
|
|
73
|
+
f"{server_url}/pairing/start",
|
|
74
|
+
json={"serverUrl": server_url, "ttlSeconds": int(args.timeout)},
|
|
75
|
+
)
|
|
76
|
+
start_response.raise_for_status()
|
|
77
|
+
pairing = start_response.json()
|
|
78
|
+
pairing_id = pairing["pairingId"]
|
|
79
|
+
code = pairing["code"]
|
|
80
|
+
|
|
81
|
+
print(f"Pairing code: {code}")
|
|
82
|
+
print("Claim it from the web UI")
|
|
83
|
+
# print(
|
|
84
|
+
# "curl -s "
|
|
85
|
+
# f"{server_url}/pairing/claim "
|
|
86
|
+
# "-H 'content-type: application/json' "
|
|
87
|
+
# f"-d '{{\"code\":\"{code}\",\"name\":\"local-codex\",\"userId\":\"local\",\"serverUrl\":\"{server_url}\"}}'"
|
|
88
|
+
# )
|
|
89
|
+
print("Waiting for credentials...")
|
|
90
|
+
|
|
91
|
+
deadline = time.monotonic() + args.timeout
|
|
92
|
+
while time.monotonic() < deadline:
|
|
93
|
+
poll_response = await client.post(f"{server_url}/pairing/poll", json={"pairingId": pairing_id})
|
|
94
|
+
poll_response.raise_for_status()
|
|
95
|
+
payload = poll_response.json()
|
|
96
|
+
if payload["status"] == "claimed" and payload.get("config"):
|
|
97
|
+
config = ConnectorConfig.from_mapping(payload["config"])
|
|
98
|
+
path = config.save(args.config)
|
|
99
|
+
print(f"Saved connector config: {path}")
|
|
100
|
+
if args.no_start:
|
|
101
|
+
return
|
|
102
|
+
print("Starting connector...")
|
|
103
|
+
await BackendRpcClient(config).run_forever()
|
|
104
|
+
return
|
|
105
|
+
if payload["status"] in {"expired", "consumed"}:
|
|
106
|
+
raise RuntimeError(f"pairing ended with status: {payload['status']}")
|
|
107
|
+
await asyncio.sleep(args.poll_interval)
|
|
108
|
+
|
|
109
|
+
raise TimeoutError("pairing timed out")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _configure(args: argparse.Namespace) -> None:
|
|
113
|
+
config = ConnectorConfig(
|
|
114
|
+
server_url=args.server_url.rstrip("/"),
|
|
115
|
+
connector_id=args.connector_id,
|
|
116
|
+
connector_token=args.connector_token,
|
|
117
|
+
)
|
|
118
|
+
path = config.save(args.config)
|
|
119
|
+
print(f"Saved connector config: {path}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _resolve_config(args: argparse.Namespace) -> ConnectorConfig:
|
|
123
|
+
server_url = args.server_url or os.environ.get("AGENT_SERVER_URL")
|
|
124
|
+
connector_id = args.connector_id or os.environ.get("AGENT_CONNECTOR_ID")
|
|
125
|
+
connector_token = args.connector_token or os.environ.get("AGENT_CONNECTOR_TOKEN")
|
|
126
|
+
if server_url and connector_id and connector_token:
|
|
127
|
+
return ConnectorConfig(
|
|
128
|
+
server_url=server_url.rstrip("/"),
|
|
129
|
+
connector_id=connector_id,
|
|
130
|
+
connector_token=connector_token,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
config_path = Path(args.config)
|
|
134
|
+
if config_path.exists():
|
|
135
|
+
return ConnectorConfig.load(config_path)
|
|
136
|
+
|
|
137
|
+
missing = []
|
|
138
|
+
if not server_url:
|
|
139
|
+
missing.append("--server-url")
|
|
140
|
+
if not connector_id:
|
|
141
|
+
missing.append("--connector-id")
|
|
142
|
+
if not connector_token:
|
|
143
|
+
missing.append("--connector-token")
|
|
144
|
+
missing.append(f"or config file {config_path}")
|
|
145
|
+
raise SystemExit("missing connector credentials: " + ", ".join(missing))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main(sys.argv[1:])
|