anywhere-cli 0.1.0__tar.gz
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/.gitignore +24 -0
- anywhere_cli-0.1.0/PKG-INFO +110 -0
- anywhere_cli-0.1.0/README.md +94 -0
- anywhere_cli-0.1.0/connector/__init__.py +3 -0
- anywhere_cli-0.1.0/connector/adapter.py +39 -0
- anywhere_cli-0.1.0/connector/attachments.py +36 -0
- anywhere_cli-0.1.0/connector/capabilities.py +334 -0
- anywhere_cli-0.1.0/connector/claude/__init__.py +8 -0
- anywhere_cli-0.1.0/connector/claude/history_adapter.py +642 -0
- anywhere_cli-0.1.0/connector/claude/normalized.py +23 -0
- anywhere_cli-0.1.0/connector/claude/normalizers.py +97 -0
- anywhere_cli-0.1.0/connector/claude/path_utils.py +13 -0
- anywhere_cli-0.1.0/connector/claude/preferences.py +38 -0
- anywhere_cli-0.1.0/connector/claude/sdk_adapter.py +1377 -0
- anywhere_cli-0.1.0/connector/claude/timeline_identity.py +47 -0
- anywhere_cli-0.1.0/connector/claude/timeline_reducer.py +379 -0
- anywhere_cli-0.1.0/connector/claude/trust.py +69 -0
- anywhere_cli-0.1.0/connector/cli.py +149 -0
- anywhere_cli-0.1.0/connector/codex/__init__.py +3 -0
- anywhere_cli-0.1.0/connector/codex/adapter.py +951 -0
- anywhere_cli-0.1.0/connector/codex/history.py +199 -0
- anywhere_cli-0.1.0/connector/codex/reducer.py +1223 -0
- anywhere_cli-0.1.0/connector/codex/rpc.py +260 -0
- anywhere_cli-0.1.0/connector/launch.py +104 -0
- anywhere_cli-0.1.0/connector/local/__init__.py +6 -0
- anywhere_cli-0.1.0/connector/local/common.py +118 -0
- anywhere_cli-0.1.0/connector/local/file_ops.py +122 -0
- anywhere_cli-0.1.0/connector/local/ops.py +83 -0
- anywhere_cli-0.1.0/connector/local/shell.py +225 -0
- anywhere_cli-0.1.0/connector/local/terminal.py +389 -0
- anywhere_cli-0.1.0/connector/local_ops.py +5 -0
- anywhere_cli-0.1.0/connector/protocol.py +26 -0
- anywhere_cli-0.1.0/connector/runtime.py +1002 -0
- anywhere_cli-0.1.0/connector/sync_state.py +155 -0
- anywhere_cli-0.1.0/connector/time.py +7 -0
- anywhere_cli-0.1.0/pyproject.toml +40 -0
- anywhere_cli-0.1.0/run.sh +4 -0
- anywhere_cli-0.1.0/tests/test_claude_history_adapter.py +344 -0
- anywhere_cli-0.1.0/tests/test_claude_preferences.py +68 -0
- anywhere_cli-0.1.0/tests/test_claude_sdk_adapter.py +885 -0
- anywhere_cli-0.1.0/tests/test_claude_timeline_parity.py +252 -0
- anywhere_cli-0.1.0/tests/test_claude_trust.py +60 -0
- anywhere_cli-0.1.0/tests/test_codex_adapter.py +1207 -0
- anywhere_cli-0.1.0/tests/test_connector_capabilities.py +316 -0
- anywhere_cli-0.1.0/tests/test_connector_cli.py +104 -0
- anywhere_cli-0.1.0/tests/test_connector_runtime.py +1217 -0
- anywhere_cli-0.1.0/tests/test_terminal_backend.py +78 -0
- anywhere_cli-0.1.0/uv.lock +901 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.pytest_cache/
|
|
5
|
+
.ruff_cache/
|
|
6
|
+
.venv/
|
|
7
|
+
|
|
8
|
+
# Local Claude tooling (launch.json, transcripts, worktree metadata)
|
|
9
|
+
.claude/
|
|
10
|
+
agent-server/*-review.html
|
|
11
|
+
|
|
12
|
+
# Local runtime state
|
|
13
|
+
*.sqlite
|
|
14
|
+
*.sqlite3
|
|
15
|
+
*.db
|
|
16
|
+
agent-server/agent-server.files/
|
|
17
|
+
|
|
18
|
+
# Generated reference-doc caches live under docs/reference locally.
|
|
19
|
+
docs/reference/*
|
|
20
|
+
!docs/reference/README.md
|
|
21
|
+
|
|
22
|
+
_reference/*
|
|
23
|
+
!_reference/httpx-s3-client/
|
|
24
|
+
!_reference/httpx-s3-client/**
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: anywhere-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local runtime connector for Agents Anywhere
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: claude-agent-sdk
|
|
7
|
+
Requires-Dist: httpx>=0.28.1
|
|
8
|
+
Requires-Dist: loguru>=0.7.3
|
|
9
|
+
Requires-Dist: pexpect>=4.9.0; sys_platform != 'win32'
|
|
10
|
+
Requires-Dist: ptyprocess>=0.7.0; sys_platform != 'win32'
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Requires-Dist: pyte>=0.8.2
|
|
13
|
+
Requires-Dist: pywinpty>=2.0.13; sys_platform == 'win32'
|
|
14
|
+
Requires-Dist: websockets>=16.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Anywhere CLI
|
|
18
|
+
|
|
19
|
+
Local runtime connector for Agents Anywhere. It runs on the machine that owns
|
|
20
|
+
the workspace and agent runtimes, connects to the server over HTTP/WebSocket,
|
|
21
|
+
executes connector RPC locally, and uploads normalized runtime/session state
|
|
22
|
+
back to the backend.
|
|
23
|
+
|
|
24
|
+
## Layout
|
|
25
|
+
|
|
26
|
+
```text
|
|
27
|
+
connector/
|
|
28
|
+
claude/ Claude Code discovery, adapter, reducer, and transcript logic
|
|
29
|
+
codex/ Codex app-server discovery, RPC, adapter, and reducer logic
|
|
30
|
+
local/ Local filesystem, shell, and terminal backends
|
|
31
|
+
cli.py anywhere-cli CLI
|
|
32
|
+
runtime.py Connector config, auth, WebSocket loop, and RPC dispatch
|
|
33
|
+
tests/ Connector tests
|
|
34
|
+
pyproject.toml Connector dependencies and console script
|
|
35
|
+
run.sh Local helper for saved-config startup
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Run
|
|
39
|
+
|
|
40
|
+
Install dependencies:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv sync
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Start with explicit credentials from the web pairing flow:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uvx anywhere-cli start \
|
|
50
|
+
--server-url http://127.0.0.1:8000 \
|
|
51
|
+
--connector-id conn_xxx \
|
|
52
|
+
--connector-token cxt_xxx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Or save the config locally and start without arguments:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uvx anywhere-cli configure \
|
|
59
|
+
--server-url http://127.0.0.1:8000 \
|
|
60
|
+
--connector-id conn_xxx \
|
|
61
|
+
--connector-token cxt_xxx
|
|
62
|
+
|
|
63
|
+
uvx anywhere-cli start
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The default config path is `~/.agent-server/connector.json`. Override it with
|
|
67
|
+
`--config` or `AGENT_CONNECTOR_CONFIG`.
|
|
68
|
+
|
|
69
|
+
## Runtime Discovery
|
|
70
|
+
|
|
71
|
+
The connector discovers Codex and Claude locally and reports attached runtime
|
|
72
|
+
capabilities to the server. If a runtime is not on `PATH`, set one of:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
CODEX_BIN=/path/to/codex
|
|
76
|
+
CLAUDE_BIN=/path/to/claude
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The connector uses local runtime credentials and local filesystem permissions.
|
|
80
|
+
Agents Anywhere does not proxy Claude or Codex account credentials.
|
|
81
|
+
|
|
82
|
+
## Local Operations
|
|
83
|
+
|
|
84
|
+
The server can ask an online connector to perform local work:
|
|
85
|
+
|
|
86
|
+
- read/list/write files inside workspace-safe roots
|
|
87
|
+
- upload/download file content through the server
|
|
88
|
+
- run one-shot shell commands
|
|
89
|
+
- start and wait for shell tasks
|
|
90
|
+
- create, write, resize, stream, list, and close interactive terminals
|
|
91
|
+
- start, interrupt, sync, and approve runtime turns
|
|
92
|
+
|
|
93
|
+
## Environment
|
|
94
|
+
|
|
95
|
+
| Variable | Purpose |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `AGENT_CONNECTOR_CONFIG` | Connector config path. |
|
|
98
|
+
| `AGENT_SERVER_URL` | Server URL used when `--server-url` is omitted. |
|
|
99
|
+
| `AGENT_CONNECTOR_ID` | Connector id used when `--connector-id` is omitted. |
|
|
100
|
+
| `AGENT_CONNECTOR_TOKEN` | Connector token used when `--connector-token` is omitted. |
|
|
101
|
+
| `AGENT_CONNECTOR_ATTACHMENTS_ROOT` | Runtime attachment download directory. Defaults to `~/.agents-anywhere/attachments`. |
|
|
102
|
+
| `CODEX_BIN` | Explicit Codex CLI/app-server path. |
|
|
103
|
+
| `CLAUDE_BIN` | Explicit Claude Code CLI path. |
|
|
104
|
+
|
|
105
|
+
## Verify
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run ruff check connector tests
|
|
109
|
+
uv run pytest -q
|
|
110
|
+
```
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Anywhere CLI
|
|
2
|
+
|
|
3
|
+
Local runtime connector for Agents Anywhere. It runs on the machine that owns
|
|
4
|
+
the workspace and agent runtimes, connects to the server over HTTP/WebSocket,
|
|
5
|
+
executes connector RPC locally, and uploads normalized runtime/session state
|
|
6
|
+
back to the backend.
|
|
7
|
+
|
|
8
|
+
## Layout
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
connector/
|
|
12
|
+
claude/ Claude Code discovery, adapter, reducer, and transcript logic
|
|
13
|
+
codex/ Codex app-server discovery, RPC, adapter, and reducer logic
|
|
14
|
+
local/ Local filesystem, shell, and terminal backends
|
|
15
|
+
cli.py anywhere-cli CLI
|
|
16
|
+
runtime.py Connector config, auth, WebSocket loop, and RPC dispatch
|
|
17
|
+
tests/ Connector tests
|
|
18
|
+
pyproject.toml Connector dependencies and console script
|
|
19
|
+
run.sh Local helper for saved-config startup
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Run
|
|
23
|
+
|
|
24
|
+
Install dependencies:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uv sync
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Start with explicit credentials from the web pairing flow:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uvx anywhere-cli start \
|
|
34
|
+
--server-url http://127.0.0.1:8000 \
|
|
35
|
+
--connector-id conn_xxx \
|
|
36
|
+
--connector-token cxt_xxx
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or save the config locally and start without arguments:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uvx anywhere-cli configure \
|
|
43
|
+
--server-url http://127.0.0.1:8000 \
|
|
44
|
+
--connector-id conn_xxx \
|
|
45
|
+
--connector-token cxt_xxx
|
|
46
|
+
|
|
47
|
+
uvx anywhere-cli start
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The default config path is `~/.agent-server/connector.json`. Override it with
|
|
51
|
+
`--config` or `AGENT_CONNECTOR_CONFIG`.
|
|
52
|
+
|
|
53
|
+
## Runtime Discovery
|
|
54
|
+
|
|
55
|
+
The connector discovers Codex and Claude locally and reports attached runtime
|
|
56
|
+
capabilities to the server. If a runtime is not on `PATH`, set one of:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
CODEX_BIN=/path/to/codex
|
|
60
|
+
CLAUDE_BIN=/path/to/claude
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The connector uses local runtime credentials and local filesystem permissions.
|
|
64
|
+
Agents Anywhere does not proxy Claude or Codex account credentials.
|
|
65
|
+
|
|
66
|
+
## Local Operations
|
|
67
|
+
|
|
68
|
+
The server can ask an online connector to perform local work:
|
|
69
|
+
|
|
70
|
+
- read/list/write files inside workspace-safe roots
|
|
71
|
+
- upload/download file content through the server
|
|
72
|
+
- run one-shot shell commands
|
|
73
|
+
- start and wait for shell tasks
|
|
74
|
+
- create, write, resize, stream, list, and close interactive terminals
|
|
75
|
+
- start, interrupt, sync, and approve runtime turns
|
|
76
|
+
|
|
77
|
+
## Environment
|
|
78
|
+
|
|
79
|
+
| Variable | Purpose |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `AGENT_CONNECTOR_CONFIG` | Connector config path. |
|
|
82
|
+
| `AGENT_SERVER_URL` | Server URL used when `--server-url` is omitted. |
|
|
83
|
+
| `AGENT_CONNECTOR_ID` | Connector id used when `--connector-id` is omitted. |
|
|
84
|
+
| `AGENT_CONNECTOR_TOKEN` | Connector token used when `--connector-token` is omitted. |
|
|
85
|
+
| `AGENT_CONNECTOR_ATTACHMENTS_ROOT` | Runtime attachment download directory. Defaults to `~/.agents-anywhere/attachments`. |
|
|
86
|
+
| `CODEX_BIN` | Explicit Codex CLI/app-server path. |
|
|
87
|
+
| `CLAUDE_BIN` | Explicit Claude Code CLI path. |
|
|
88
|
+
|
|
89
|
+
## Verify
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv run ruff check connector tests
|
|
93
|
+
uv run pytest -q
|
|
94
|
+
```
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any, Protocol, runtime_checkable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
NotificationSink = Callable[[str, dict[str, Any]], Awaitable[None]] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class Adapter(Protocol):
|
|
12
|
+
"""Per-runtime backend client (Codex / Claude / OpenCode / ACP).
|
|
13
|
+
|
|
14
|
+
`BackendRpcClient` holds a dict of these keyed by runtime name and routes
|
|
15
|
+
incoming RPCs by `params["runtime"]`. Every adapter must accept a
|
|
16
|
+
`notification_sink` for pushing reduced backend notifications upstream
|
|
17
|
+
(set after construction by the client).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
notification_sink: NotificationSink
|
|
21
|
+
|
|
22
|
+
async def create_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
23
|
+
|
|
24
|
+
async def sync_session(self, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
25
|
+
|
|
26
|
+
async def sync_existing_sessions(
|
|
27
|
+
self,
|
|
28
|
+
connector_id: str,
|
|
29
|
+
*,
|
|
30
|
+
limit: int = 100,
|
|
31
|
+
force: bool = False,
|
|
32
|
+
notification_sink: Callable[[list[dict[str, Any]]], Awaitable[None]] | None = None,
|
|
33
|
+
) -> dict[str, Any]: ...
|
|
34
|
+
|
|
35
|
+
async def start_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
36
|
+
|
|
37
|
+
async def interrupt_turn(self, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
38
|
+
|
|
39
|
+
async def resolve_approval(self, params: dict[str, Any]) -> dict[str, Any]: ...
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ATTACHMENTS_ROOT_ENV = "AGENT_CONNECTOR_ATTACHMENTS_ROOT"
|
|
8
|
+
DEFAULT_ATTACHMENTS_DIR = ".agents-anywhere/attachments"
|
|
9
|
+
_SAFE_FILENAME_RE = re.compile(r"[^\w.\-+]+")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def attachments_root() -> Path:
|
|
13
|
+
"""Return the connector-local root used for runtime attachment copies."""
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
configured = os.environ.get(ATTACHMENTS_ROOT_ENV)
|
|
17
|
+
root = Path(configured).expanduser() if configured else Path.home() / DEFAULT_ATTACHMENTS_DIR
|
|
18
|
+
return root.resolve(strict=False)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def session_attachments_dir(session_id: str) -> Path:
|
|
22
|
+
session = _safe_filename(session_id) or "session"
|
|
23
|
+
return attachments_root() / session
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def attachment_target(session_id: str, file_id: str, original_name: str | None) -> Path:
|
|
27
|
+
safe_file_id = _safe_filename(file_id) or "file"
|
|
28
|
+
safe_name = _safe_filename(original_name or "") or safe_file_id
|
|
29
|
+
return session_attachments_dir(session_id) / f"{safe_file_id}-{safe_name}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _safe_filename(name: str) -> str:
|
|
33
|
+
"""Reduce arbitrary user/server values to safe single path components."""
|
|
34
|
+
name = name.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
|
35
|
+
sanitized = _SAFE_FILENAME_RE.sub("_", name).strip("._") or ""
|
|
36
|
+
return sanitized[:120]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from connector.launch import LaunchTarget, launch_target, path_exists_for_launch
|
|
15
|
+
from connector.codex.rpc import JsonRpcStdioClient, codex_candidate_paths
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_CODEX_CHECK_TIMEOUT_S = 8.0
|
|
19
|
+
_COMMAND_CHECK_TIMEOUT_S = 8.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class RuntimeDiscovery:
|
|
24
|
+
report: dict[str, Any]
|
|
25
|
+
codex_bin: str | None = None
|
|
26
|
+
claude_bin: str | None = None
|
|
27
|
+
codex_target: LaunchTarget | None = None
|
|
28
|
+
claude_target: LaunchTarget | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def discover_runtime_capabilities() -> RuntimeDiscovery:
|
|
32
|
+
started = time.perf_counter()
|
|
33
|
+
codex_report, codex_target = await discover_codex_capability()
|
|
34
|
+
claude_report, claude_target = await discover_claude_capability()
|
|
35
|
+
return RuntimeDiscovery(
|
|
36
|
+
report={
|
|
37
|
+
"version": 1,
|
|
38
|
+
"checkedAt": _now_iso(),
|
|
39
|
+
"elapsedMs": round((time.perf_counter() - started) * 1000, 1),
|
|
40
|
+
"runtimes": {
|
|
41
|
+
"codex": codex_report,
|
|
42
|
+
"claude": claude_report,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
codex_bin=codex_target.path if codex_target else None,
|
|
46
|
+
claude_bin=claude_target.path if claude_target else None,
|
|
47
|
+
codex_target=codex_target,
|
|
48
|
+
claude_target=claude_target,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def discover_codex_capability(
|
|
53
|
+
*, extra_candidate: str | None = None
|
|
54
|
+
) -> tuple[dict[str, Any], LaunchTarget | None]:
|
|
55
|
+
"""Scan the local machine for a usable Codex install.
|
|
56
|
+
|
|
57
|
+
`extra_candidate`, when set, is checked first as `source="custom"`. Used
|
|
58
|
+
by the per-runtime scan endpoint when the user types a custom path in the
|
|
59
|
+
Add Agent modal.
|
|
60
|
+
"""
|
|
61
|
+
candidates = codex_candidate_paths()
|
|
62
|
+
if extra_candidate:
|
|
63
|
+
candidates = _dedupe_candidates(
|
|
64
|
+
[{"source": "custom", "path": extra_candidate}, *candidates]
|
|
65
|
+
)
|
|
66
|
+
checked: list[dict[str, Any]] = []
|
|
67
|
+
for candidate in candidates:
|
|
68
|
+
target = _target_from_candidate(candidate)
|
|
69
|
+
result = await _check_codex_candidate(candidate)
|
|
70
|
+
checked.append(result)
|
|
71
|
+
if result["status"] == "ok":
|
|
72
|
+
return (
|
|
73
|
+
{
|
|
74
|
+
"history": "ok",
|
|
75
|
+
"execution": "ok",
|
|
76
|
+
"selected": _selected_from_check(result),
|
|
77
|
+
"checked": checked,
|
|
78
|
+
},
|
|
79
|
+
target,
|
|
80
|
+
)
|
|
81
|
+
return (
|
|
82
|
+
{
|
|
83
|
+
"history": "unavailable",
|
|
84
|
+
"execution": "unavailable",
|
|
85
|
+
"error": {
|
|
86
|
+
"code": "codex_unavailable",
|
|
87
|
+
"message": (
|
|
88
|
+
"Codex is unavailable or broken. Checked custom path, Codex App, "
|
|
89
|
+
"and Codex CLI. Plugin-based Codex installations are not supported yet."
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
"checked": checked,
|
|
93
|
+
},
|
|
94
|
+
None,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def discover_claude_capability(
|
|
99
|
+
*, extra_candidate: str | None = None
|
|
100
|
+
) -> tuple[dict[str, Any], LaunchTarget | None]:
|
|
101
|
+
history = _check_claude_history()
|
|
102
|
+
candidates = _claude_candidate_paths()
|
|
103
|
+
if extra_candidate:
|
|
104
|
+
candidates = _dedupe_candidates(
|
|
105
|
+
[{"source": "custom", "path": extra_candidate}, *candidates]
|
|
106
|
+
)
|
|
107
|
+
checked: list[dict[str, Any]] = []
|
|
108
|
+
selected_target: LaunchTarget | None = None
|
|
109
|
+
execution = "unavailable"
|
|
110
|
+
for candidate in candidates:
|
|
111
|
+
target = _target_from_candidate(candidate)
|
|
112
|
+
result = await _check_claude_candidate(candidate)
|
|
113
|
+
checked.append(result)
|
|
114
|
+
if result["status"] == "ok":
|
|
115
|
+
selected_target = target
|
|
116
|
+
execution = "ok"
|
|
117
|
+
break
|
|
118
|
+
|
|
119
|
+
report: dict[str, Any] = {
|
|
120
|
+
"history": history["status"],
|
|
121
|
+
"execution": execution,
|
|
122
|
+
"historyCheck": history,
|
|
123
|
+
"checked": checked,
|
|
124
|
+
}
|
|
125
|
+
if selected_target is not None:
|
|
126
|
+
report["selected"] = _selected_from_check(checked[-1])
|
|
127
|
+
else:
|
|
128
|
+
report["error"] = {
|
|
129
|
+
"code": "claude_cli_unavailable",
|
|
130
|
+
"message": "Claude Code is unavailable or broken. Checked CLAUDE_BIN, PATH, and common install paths.",
|
|
131
|
+
}
|
|
132
|
+
return report, selected_target
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
|
|
136
|
+
target = _target_from_candidate(candidate)
|
|
137
|
+
path = target.path
|
|
138
|
+
source = target.source
|
|
139
|
+
base = {"source": source, "path": path}
|
|
140
|
+
if not Path(path).is_file():
|
|
141
|
+
return {**base, "status": "missing", "reason": "file not found"}
|
|
142
|
+
if not path_exists_for_launch(path):
|
|
143
|
+
return {**base, "status": "failed", "reason": "not executable"}
|
|
144
|
+
|
|
145
|
+
version = await _run_version(target.command(["--version"]))
|
|
146
|
+
if version["status"] != "ok":
|
|
147
|
+
return {**base, "status": "failed", "stage": "version", **version}
|
|
148
|
+
|
|
149
|
+
client = JsonRpcStdioClient(command=target.command(["app-server", "--listen", "stdio://"]))
|
|
150
|
+
try:
|
|
151
|
+
await asyncio.wait_for(client.start(lambda _payload: _noop()), timeout=_CODEX_CHECK_TIMEOUT_S)
|
|
152
|
+
list_result = await asyncio.wait_for(
|
|
153
|
+
client.request("thread/list", {"limit": 1, "sortKey": "updated_at"}),
|
|
154
|
+
timeout=_CODEX_CHECK_TIMEOUT_S,
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
return {
|
|
158
|
+
**base,
|
|
159
|
+
"status": "failed",
|
|
160
|
+
"stage": "app-server",
|
|
161
|
+
"version": version.get("stdout"),
|
|
162
|
+
"reason": _exception_reason(exc),
|
|
163
|
+
}
|
|
164
|
+
finally:
|
|
165
|
+
try:
|
|
166
|
+
await client.close()
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
**base,
|
|
172
|
+
"status": "ok",
|
|
173
|
+
"version": version.get("stdout"),
|
|
174
|
+
"threadListKeys": sorted(list_result.keys()),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _check_claude_candidate(candidate: dict[str, str] | LaunchTarget) -> dict[str, Any]:
|
|
179
|
+
target = _target_from_candidate(candidate)
|
|
180
|
+
path = target.path
|
|
181
|
+
source = target.source
|
|
182
|
+
base = {"source": source, "path": path}
|
|
183
|
+
if not Path(path).is_file():
|
|
184
|
+
return {**base, "status": "missing", "reason": "file not found"}
|
|
185
|
+
if not path_exists_for_launch(path):
|
|
186
|
+
return {**base, "status": "failed", "reason": "not executable"}
|
|
187
|
+
|
|
188
|
+
version = await _run_version(target.command(["--version"]))
|
|
189
|
+
if version["status"] != "ok":
|
|
190
|
+
return {**base, "status": "failed", "stage": "version", **version}
|
|
191
|
+
help_result = await _run_version(target.command(["--help"]))
|
|
192
|
+
if help_result["status"] != "ok":
|
|
193
|
+
return {
|
|
194
|
+
**base,
|
|
195
|
+
"status": "failed",
|
|
196
|
+
"stage": "help",
|
|
197
|
+
"version": version.get("stdout"),
|
|
198
|
+
"reason": help_result.get("reason"),
|
|
199
|
+
}
|
|
200
|
+
return {**base, "status": "ok", "version": version.get("stdout")}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _check_claude_history() -> dict[str, Any]:
|
|
204
|
+
source = "claude-agent-sdk"
|
|
205
|
+
api = "list_sessions"
|
|
206
|
+
try:
|
|
207
|
+
sessions = _list_claude_sdk_sessions()
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
return {
|
|
210
|
+
"status": "unavailable",
|
|
211
|
+
"source": source,
|
|
212
|
+
"api": api,
|
|
213
|
+
"reason": _exception_reason(exc),
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
"status": "ok" if sessions else "ok_empty",
|
|
217
|
+
"source": source,
|
|
218
|
+
"api": api,
|
|
219
|
+
"sessionCount": len(sessions),
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _list_claude_sdk_sessions() -> list[Any]:
|
|
224
|
+
sdk = importlib.import_module("claude_agent_sdk")
|
|
225
|
+
list_sessions = getattr(sdk, "list_sessions")
|
|
226
|
+
return list(list_sessions())
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
async def _run_version(command: list[str]) -> dict[str, Any]:
|
|
230
|
+
try:
|
|
231
|
+
proc = await asyncio.create_subprocess_exec(
|
|
232
|
+
*command,
|
|
233
|
+
stdout=asyncio.subprocess.PIPE,
|
|
234
|
+
stderr=asyncio.subprocess.PIPE,
|
|
235
|
+
)
|
|
236
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
return {"status": "failed", "reason": _exception_reason(exc)}
|
|
239
|
+
out = stdout.decode(errors="replace").strip()
|
|
240
|
+
err = stderr.decode(errors="replace").strip()
|
|
241
|
+
if proc.returncode != 0:
|
|
242
|
+
return {
|
|
243
|
+
"status": "failed",
|
|
244
|
+
"reason": f"exit {proc.returncode}",
|
|
245
|
+
"stdout": out[:500],
|
|
246
|
+
"stderr": err[:500],
|
|
247
|
+
}
|
|
248
|
+
return {"status": "ok", "stdout": out[:500], "stderr": err[:500]}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _claude_candidate_paths() -> list[dict[str, str]]:
|
|
252
|
+
if sys.platform == "win32":
|
|
253
|
+
home = Path.home()
|
|
254
|
+
appdata = os.environ.get("APPDATA", str(home / "AppData" / "Roaming"))
|
|
255
|
+
return _dedupe_candidates(
|
|
256
|
+
[
|
|
257
|
+
{"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
|
|
258
|
+
{"source": "cli", "path": shutil.which("claude") or ""},
|
|
259
|
+
*[
|
|
260
|
+
{"source": "cli", "path": str(home / ".local" / "bin" / name)}
|
|
261
|
+
for name in ("claude.exe", "claude.cmd", "claude.ps1")
|
|
262
|
+
],
|
|
263
|
+
*[
|
|
264
|
+
{"source": "npm", "path": str(Path(appdata) / "npm" / name)}
|
|
265
|
+
for name in ("claude.cmd", "claude.ps1", "claude.exe")
|
|
266
|
+
],
|
|
267
|
+
*[
|
|
268
|
+
{"source": "npm", "path": str(home / ".npm-global" / "bin" / name)}
|
|
269
|
+
for name in ("claude.cmd", "claude.ps1", "claude.exe")
|
|
270
|
+
],
|
|
271
|
+
*[
|
|
272
|
+
{"source": "nvm", "path": str(Path("C:/nvm4w/nodejs") / name)}
|
|
273
|
+
for name in ("claude.cmd", "claude.ps1", "claude.exe")
|
|
274
|
+
],
|
|
275
|
+
*[
|
|
276
|
+
{"source": "scoop", "path": str(home / "scoop" / "shims" / name)}
|
|
277
|
+
for name in ("claude.exe", "claude.cmd", "claude.ps1")
|
|
278
|
+
],
|
|
279
|
+
]
|
|
280
|
+
)
|
|
281
|
+
return _dedupe_candidates(
|
|
282
|
+
[
|
|
283
|
+
{"source": "custom", "path": os.environ.get("CLAUDE_BIN", "")},
|
|
284
|
+
{"source": "cli", "path": shutil.which("claude") or ""},
|
|
285
|
+
{"source": "cli", "path": str(Path.home() / ".npm-global" / "bin" / "claude")},
|
|
286
|
+
{"source": "cli", "path": str(Path.home() / ".local" / "bin" / "claude")},
|
|
287
|
+
{"source": "cli", "path": "/opt/homebrew/bin/claude"},
|
|
288
|
+
{"source": "cli", "path": "/usr/local/bin/claude"},
|
|
289
|
+
]
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _target_from_candidate(candidate: dict[str, str] | LaunchTarget) -> LaunchTarget:
|
|
294
|
+
if isinstance(candidate, LaunchTarget):
|
|
295
|
+
return candidate
|
|
296
|
+
return launch_target(candidate["source"], candidate["path"])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _dedupe_candidates(candidates: Iterable[dict[str, str]]) -> list[dict[str, str]]:
|
|
300
|
+
seen: set[str] = set()
|
|
301
|
+
out: list[dict[str, str]] = []
|
|
302
|
+
for candidate in candidates:
|
|
303
|
+
path = candidate.get("path") or ""
|
|
304
|
+
if not path or path in seen:
|
|
305
|
+
continue
|
|
306
|
+
seen.add(path)
|
|
307
|
+
out.append(candidate)
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _selected_from_check(result: dict[str, Any]) -> dict[str, Any]:
|
|
312
|
+
selected = {
|
|
313
|
+
"source": result["source"],
|
|
314
|
+
"path": result["path"],
|
|
315
|
+
}
|
|
316
|
+
if result.get("version"):
|
|
317
|
+
selected["version"] = result["version"]
|
|
318
|
+
return selected
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _exception_reason(exc: BaseException) -> str:
|
|
322
|
+
if isinstance(exc, TimeoutError):
|
|
323
|
+
return "timeout"
|
|
324
|
+
return str(exc) or exc.__class__.__name__
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _now_iso() -> str:
|
|
328
|
+
from datetime import UTC, datetime
|
|
329
|
+
|
|
330
|
+
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
async def _noop() -> None:
|
|
334
|
+
return None
|