cordis-bridge 0.1.1__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.
- cordis_bridge-0.1.1/LICENSE +21 -0
- cordis_bridge-0.1.1/PKG-INFO +82 -0
- cordis_bridge-0.1.1/README.md +61 -0
- cordis_bridge-0.1.1/pyproject.toml +38 -0
- cordis_bridge-0.1.1/setup.cfg +4 -0
- cordis_bridge-0.1.1/src/cordis_bridge/__init__.py +7 -0
- cordis_bridge-0.1.1/src/cordis_bridge/cli.py +105 -0
- cordis_bridge-0.1.1/src/cordis_bridge/mcp_server.py +79 -0
- cordis_bridge-0.1.1/src/cordis_bridge/service.py +165 -0
- cordis_bridge-0.1.1/src/cordis_bridge/setup.py +274 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/PKG-INFO +82 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/SOURCES.txt +18 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/dependency_links.txt +1 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/entry_points.txt +3 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/requires.txt +6 -0
- cordis_bridge-0.1.1/src/cordis_bridge.egg-info/top_level.txt +1 -0
- cordis_bridge-0.1.1/tests/test_cli.py +91 -0
- cordis_bridge-0.1.1/tests/test_mcp.py +71 -0
- cordis_bridge-0.1.1/tests/test_service.py +85 -0
- cordis_bridge-0.1.1/tests/test_setup.py +103 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cordis Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cordis-bridge
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI and optional MCP bridge for the Cordis cognitive runtime.
|
|
5
|
+
Author: Cordis Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: cordis-core<0.2,>=0.1.1
|
|
16
|
+
Requires-Dist: cordis-memory<0.2,>=0.1.1
|
|
17
|
+
Requires-Dist: cordis-runtime<0.2,>=0.1.1
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp<2,>=1.26; extra == "mcp"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Cordis Bridge v0.1
|
|
23
|
+
|
|
24
|
+
`cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
|
|
25
|
+
It has no model, planner, HTTP server, or execution permission of its own.
|
|
26
|
+
|
|
27
|
+
The bridge provides one shared Service behind two transports:
|
|
28
|
+
|
|
29
|
+
- a JSON-only CLI for local hosts and scripts;
|
|
30
|
+
- an optional stdio MCP server for agent hosts.
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
host -> begin -> compact context + control signal
|
|
34
|
+
host -> observe / check-action -> workflow evidence
|
|
35
|
+
host -> finish -> Core feedback + Memory episode
|
|
36
|
+
later host -> begin -> changed cognition
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
By default, durable state lives in `.cordis/` below the current directory:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
.cordis/state.json Core learning state
|
|
43
|
+
.cordis/cognition.db Memory and relationship graph
|
|
44
|
+
.cordis/focus.json Active host-task focus for CLI/MCP process recovery
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## CLI
|
|
48
|
+
|
|
49
|
+
Every command reads one JSON object from a file or standard input and emits
|
|
50
|
+
one JSON object. The input file defaults to `-` (standard input).
|
|
51
|
+
|
|
52
|
+
```bat
|
|
53
|
+
cordis begin task.json
|
|
54
|
+
cordis query query.json
|
|
55
|
+
cordis observe event.json
|
|
56
|
+
cordis check-action action.json
|
|
57
|
+
cordis finish feedback.json
|
|
58
|
+
cordis status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `--data-dir PATH` before the command to keep a project-specific state
|
|
62
|
+
directory elsewhere.
|
|
63
|
+
|
|
64
|
+
## MCP
|
|
65
|
+
|
|
66
|
+
Install the optional transport dependency and run:
|
|
67
|
+
|
|
68
|
+
```bat
|
|
69
|
+
pip install "cordis-bridge[mcp]"
|
|
70
|
+
cordis-mcp --data-dir .cordis
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
|
|
74
|
+
`cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
|
|
75
|
+
the same JSON contract as the matching CLI command.
|
|
76
|
+
|
|
77
|
+
## Concurrency boundary
|
|
78
|
+
|
|
79
|
+
CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
|
|
80
|
+
per `.cordis` directory and keep CLI mutations for that directory sequential.
|
|
81
|
+
The Core JSON state and Bridge focus snapshot are deliberately small local
|
|
82
|
+
stores; multi-writer coordination is not part of this release.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Cordis Bridge v0.1
|
|
2
|
+
|
|
3
|
+
`cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
|
|
4
|
+
It has no model, planner, HTTP server, or execution permission of its own.
|
|
5
|
+
|
|
6
|
+
The bridge provides one shared Service behind two transports:
|
|
7
|
+
|
|
8
|
+
- a JSON-only CLI for local hosts and scripts;
|
|
9
|
+
- an optional stdio MCP server for agent hosts.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
host -> begin -> compact context + control signal
|
|
13
|
+
host -> observe / check-action -> workflow evidence
|
|
14
|
+
host -> finish -> Core feedback + Memory episode
|
|
15
|
+
later host -> begin -> changed cognition
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
By default, durable state lives in `.cordis/` below the current directory:
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
.cordis/state.json Core learning state
|
|
22
|
+
.cordis/cognition.db Memory and relationship graph
|
|
23
|
+
.cordis/focus.json Active host-task focus for CLI/MCP process recovery
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## CLI
|
|
27
|
+
|
|
28
|
+
Every command reads one JSON object from a file or standard input and emits
|
|
29
|
+
one JSON object. The input file defaults to `-` (standard input).
|
|
30
|
+
|
|
31
|
+
```bat
|
|
32
|
+
cordis begin task.json
|
|
33
|
+
cordis query query.json
|
|
34
|
+
cordis observe event.json
|
|
35
|
+
cordis check-action action.json
|
|
36
|
+
cordis finish feedback.json
|
|
37
|
+
cordis status
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Use `--data-dir PATH` before the command to keep a project-specific state
|
|
41
|
+
directory elsewhere.
|
|
42
|
+
|
|
43
|
+
## MCP
|
|
44
|
+
|
|
45
|
+
Install the optional transport dependency and run:
|
|
46
|
+
|
|
47
|
+
```bat
|
|
48
|
+
pip install "cordis-bridge[mcp]"
|
|
49
|
+
cordis-mcp --data-dir .cordis
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
|
|
53
|
+
`cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
|
|
54
|
+
the same JSON contract as the matching CLI command.
|
|
55
|
+
|
|
56
|
+
## Concurrency boundary
|
|
57
|
+
|
|
58
|
+
CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
|
|
59
|
+
per `.cordis` directory and keep CLI mutations for that directory sequential.
|
|
60
|
+
The Core JSON state and Bridge focus snapshot are deliberately small local
|
|
61
|
+
stores; multi-writer coordination is not part of this release.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cordis-bridge"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "CLI and optional MCP bridge for the Cordis cognitive runtime."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Cordis Contributors"}]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"cordis-core>=0.1.1,<0.2",
|
|
15
|
+
"cordis-memory>=0.1.1,<0.2",
|
|
16
|
+
"cordis-runtime>=0.1.1,<0.2",
|
|
17
|
+
]
|
|
18
|
+
classifiers = [
|
|
19
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
20
|
+
"Intended Audience :: Developers",
|
|
21
|
+
"License :: OSI Approved :: MIT License",
|
|
22
|
+
"Programming Language :: Python :: 3",
|
|
23
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
mcp = ["mcp>=1.26,<2"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
cordis = "cordis_bridge.cli:main"
|
|
31
|
+
cordis-mcp = "cordis_bridge.mcp_server:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
38
|
+
pythonpath = ["src", "../cordis-core/src", "../cordis-memory/src", "../cordis-runtime/src"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""JSON-only local command line transport for CORDIS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Callable, Mapping
|
|
10
|
+
|
|
11
|
+
from cordis_core import CordisValidationError
|
|
12
|
+
from cordis_memory import MemoryValidationError
|
|
13
|
+
from cordis_runtime import RuntimeValidationError
|
|
14
|
+
|
|
15
|
+
from .service import BridgeValidationError, CordisService
|
|
16
|
+
from .setup import SetupError, setup_claude_code, setup_codex, setup_hermes, setup_opencode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
USER_ERRORS = (BridgeValidationError, CordisValidationError, MemoryValidationError, RuntimeValidationError, SetupError)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_json(source: str) -> Mapping[str, Any]:
|
|
23
|
+
try:
|
|
24
|
+
raw = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
|
|
25
|
+
value = json.loads(raw)
|
|
26
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
27
|
+
raise BridgeValidationError(f"unable to read JSON input: {error}") from error
|
|
28
|
+
if not isinstance(value, dict):
|
|
29
|
+
raise BridgeValidationError("JSON input must be one object")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _write_json(value: Mapping[str, Any]) -> None:
|
|
34
|
+
sys.stdout.write(json.dumps(value, ensure_ascii=False, sort_keys=True) + "\n")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parser() -> argparse.ArgumentParser:
|
|
38
|
+
parser = argparse.ArgumentParser(prog="cordis", description="JSON bridge for the CORDIS cognitive runtime")
|
|
39
|
+
parser.add_argument("--data-dir", default=".cordis", help="directory containing CORDIS durable state")
|
|
40
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
for name in ("begin", "query", "observe", "check-action", "finish"):
|
|
42
|
+
command = sub.add_parser(name)
|
|
43
|
+
command.add_argument("input", nargs="?", default="-", help="JSON file path or - for standard input")
|
|
44
|
+
sub.add_parser("status")
|
|
45
|
+
sub.add_parser("init", help="initialize a local CORDIS state directory")
|
|
46
|
+
setup = sub.add_parser("setup", help="connect CORDIS to a supported host")
|
|
47
|
+
setup.add_argument("host", choices=("codex", "claude-code", "opencode", "hermes", "all"))
|
|
48
|
+
setup.add_argument("--config", help=argparse.SUPPRESS)
|
|
49
|
+
return parser
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
args = _parser().parse_args(argv)
|
|
54
|
+
try:
|
|
55
|
+
if args.command == "setup":
|
|
56
|
+
setups = {
|
|
57
|
+
"codex": lambda: setup_codex(args.config),
|
|
58
|
+
"claude-code": setup_claude_code,
|
|
59
|
+
"opencode": lambda: setup_opencode(args.config),
|
|
60
|
+
"hermes": setup_hermes,
|
|
61
|
+
}
|
|
62
|
+
selected = tuple(setups) if args.host == "all" else (args.host,)
|
|
63
|
+
results = []
|
|
64
|
+
for host in selected:
|
|
65
|
+
result = setups[host]()
|
|
66
|
+
with CordisService(result["state_dir"]) as service:
|
|
67
|
+
service.initialize()
|
|
68
|
+
result["initialized_data_dir"] = result["state_dir"]
|
|
69
|
+
results.append(result)
|
|
70
|
+
result = {"schema": "cordis.setup.v1", "hosts": results} if args.host == "all" else results[0]
|
|
71
|
+
_write_json(result)
|
|
72
|
+
return 0
|
|
73
|
+
with CordisService(args.data_dir) as service:
|
|
74
|
+
if args.command == "init":
|
|
75
|
+
service.initialize()
|
|
76
|
+
result = {
|
|
77
|
+
"schema": "cordis.bridge.v1",
|
|
78
|
+
"initialized": True,
|
|
79
|
+
"data_dir": str(service.data_dir),
|
|
80
|
+
"next": ["cordis status", "cordis-mcp --data-dir .cordis"],
|
|
81
|
+
}
|
|
82
|
+
elif args.command == "status":
|
|
83
|
+
result = service.status()
|
|
84
|
+
else:
|
|
85
|
+
request = _read_json(args.input)
|
|
86
|
+
commands: dict[str, Callable[[Mapping[str, Any]], dict[str, Any]]] = {
|
|
87
|
+
"begin": service.begin,
|
|
88
|
+
"query": service.query,
|
|
89
|
+
"observe": service.observe,
|
|
90
|
+
"check-action": service.check_action,
|
|
91
|
+
"finish": service.finish,
|
|
92
|
+
}
|
|
93
|
+
result = commands[args.command](request)
|
|
94
|
+
_write_json(result)
|
|
95
|
+
return 0
|
|
96
|
+
except USER_ERRORS as error:
|
|
97
|
+
_write_json({"error": {"type": "validation_error", "message": str(error)}})
|
|
98
|
+
return 2
|
|
99
|
+
except Exception as error: # Keep the machine-readable contract even for host failures.
|
|
100
|
+
_write_json({"error": {"type": "internal_error", "message": str(error)}})
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Optional FastMCP transport; importing this module does not require MCP."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .service import CordisService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class McpUnavailableError(RuntimeError):
|
|
13
|
+
"""Raised only when the optional ``mcp`` dependency is absent."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create_server(data_dir: str | Path | None = None) -> Any:
|
|
17
|
+
"""Build a stdio MCP server around one persistent Bridge service."""
|
|
18
|
+
try:
|
|
19
|
+
from mcp.server.fastmcp import FastMCP
|
|
20
|
+
except ImportError as error:
|
|
21
|
+
raise McpUnavailableError("MCP support requires: pip install 'cordis-bridge[mcp]'") from error
|
|
22
|
+
|
|
23
|
+
service = CordisService(data_dir)
|
|
24
|
+
server = FastMCP(
|
|
25
|
+
"CORDIS",
|
|
26
|
+
instructions=(
|
|
27
|
+
"CORDIS is a model-agnostic cognitive runtime. Call cordis_begin before work, "
|
|
28
|
+
"record meaningful events, then call cordis_finish with observable evidence."
|
|
29
|
+
),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
@server.tool(name="cordis_begin", description="Start a structured CORDIS task and receive compact cognitive context.")
|
|
33
|
+
def cordis_begin(payload: dict[str, Any]) -> dict[str, Any]:
|
|
34
|
+
return service.begin(payload)
|
|
35
|
+
|
|
36
|
+
@server.tool(name="cordis_query", description="Retrieve novel project-safe cognition for an active CORDIS task.")
|
|
37
|
+
def cordis_query(payload: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
return service.query(payload)
|
|
39
|
+
|
|
40
|
+
@server.tool(name="cordis_observe", description="Record a plan, tool, test, artifact, or error event for an active task.")
|
|
41
|
+
def cordis_observe(payload: dict[str, Any]) -> dict[str, Any]:
|
|
42
|
+
return service.observe(payload)
|
|
43
|
+
|
|
44
|
+
@server.tool(name="cordis_check_action", description="Return a deterministic attention-drift and destructive-action signal.")
|
|
45
|
+
def cordis_check_action(payload: dict[str, Any]) -> dict[str, Any]:
|
|
46
|
+
return service.check_action(payload)
|
|
47
|
+
|
|
48
|
+
@server.tool(name="cordis_finish", description="Finalize a task with outcome, evidence, attribution, and optional lesson.")
|
|
49
|
+
def cordis_finish(payload: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
return service.finish(payload)
|
|
51
|
+
|
|
52
|
+
@server.tool(name="cordis_status", description="Return observable Core, Memory, and active-task state.")
|
|
53
|
+
def cordis_status() -> dict[str, Any]:
|
|
54
|
+
return service.status()
|
|
55
|
+
|
|
56
|
+
# Keep ownership explicit for embedding hosts and Bridge tests. FastMCP
|
|
57
|
+
# itself does not manage application resources constructed outside a
|
|
58
|
+
# lifespan callback.
|
|
59
|
+
setattr(server, "cordis_service", service)
|
|
60
|
+
return server
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv: list[str] | None = None) -> int:
|
|
64
|
+
parser = argparse.ArgumentParser(prog="cordis-mcp", description="Run CORDIS as a stdio MCP server")
|
|
65
|
+
parser.add_argument("--data-dir", default=".cordis", help="directory containing CORDIS durable state")
|
|
66
|
+
args = parser.parse_args(argv)
|
|
67
|
+
try:
|
|
68
|
+
server = create_server(args.data_dir)
|
|
69
|
+
except McpUnavailableError as error:
|
|
70
|
+
parser.error(str(error))
|
|
71
|
+
try:
|
|
72
|
+
server.run(transport="stdio")
|
|
73
|
+
finally:
|
|
74
|
+
server.cordis_service.close()
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Persistent host service shared by the CORDIS CLI and MCP transports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Iterable, Mapping
|
|
10
|
+
|
|
11
|
+
from cordis_core import CordisRuntime
|
|
12
|
+
from cordis_memory import CognitiveStore
|
|
13
|
+
from cordis_runtime import CordisHostRuntime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
BRIDGE_SCHEMA = "cordis.bridge.v1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BridgeValidationError(ValueError):
|
|
20
|
+
"""Raised when a transport breaks the small Bridge request contract."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _object(value: Any, field: str = "payload") -> Mapping[str, Any]:
|
|
24
|
+
if not isinstance(value, Mapping):
|
|
25
|
+
raise BridgeValidationError(f"{field} must be an object")
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _text(value: Any, field: str) -> str:
|
|
30
|
+
if not isinstance(value, str) or not value.strip():
|
|
31
|
+
raise BridgeValidationError(f"{field} must be a non-empty string")
|
|
32
|
+
return value.strip()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _string_list(value: Any, field: str) -> tuple[str, ...] | None:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
if not isinstance(value, list) or any(not isinstance(item, str) or not item.strip() for item in value):
|
|
39
|
+
raise BridgeValidationError(f"{field} must be a list of non-empty strings")
|
|
40
|
+
return tuple(item.strip() for item in value)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CordisService:
|
|
44
|
+
"""Persist host focus around the existing Core, Memory, and Runtime APIs.
|
|
45
|
+
|
|
46
|
+
Core owns learning state in ``state.json`` and Memory owns graph state in
|
|
47
|
+
``cognition.db``. The tiny ``focus.json`` file exists only because CLI
|
|
48
|
+
and MCP calls can arrive in separate processes while Runtime focus is
|
|
49
|
+
intentionally task-local and in-memory.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(self, data_dir: str | Path | None = None):
|
|
53
|
+
self.data_dir = Path(data_dir or ".cordis").resolve()
|
|
54
|
+
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
self.focus_path = self.data_dir / "focus.json"
|
|
56
|
+
self.core = CordisRuntime(self.data_dir / "state.json")
|
|
57
|
+
self.memory = CognitiveStore(self.data_dir / "cognition.db")
|
|
58
|
+
self.runtime = CordisHostRuntime(self.core, self.memory)
|
|
59
|
+
self._closed = False
|
|
60
|
+
self._restore_focus()
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
if not self._closed:
|
|
64
|
+
self.memory.close()
|
|
65
|
+
self._closed = True
|
|
66
|
+
|
|
67
|
+
def __enter__(self) -> "CordisService":
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *_: Any) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
def _restore_focus(self) -> None:
|
|
74
|
+
if not self.focus_path.exists():
|
|
75
|
+
return
|
|
76
|
+
try:
|
|
77
|
+
raw = json.loads(self.focus_path.read_text(encoding="utf-8"))
|
|
78
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
79
|
+
raise BridgeValidationError("focus.json is unreadable; remove it only after resolving active tasks") from error
|
|
80
|
+
if not isinstance(raw, dict) or raw.get("schema") != BRIDGE_SCHEMA or not isinstance(raw.get("tasks"), dict):
|
|
81
|
+
raise BridgeValidationError("focus.json has an unsupported schema")
|
|
82
|
+
tasks = raw["tasks"]
|
|
83
|
+
if any(not isinstance(task_id, str) or not isinstance(focus, dict) for task_id, focus in tasks.items()):
|
|
84
|
+
raise BridgeValidationError("focus.json contains invalid task focus")
|
|
85
|
+
self.runtime.restore_focus(tasks)
|
|
86
|
+
|
|
87
|
+
def _save_focus(self) -> None:
|
|
88
|
+
payload = {
|
|
89
|
+
"schema": BRIDGE_SCHEMA,
|
|
90
|
+
"tasks": self.runtime.export_focus(),
|
|
91
|
+
}
|
|
92
|
+
handle = tempfile.NamedTemporaryFile(
|
|
93
|
+
mode="w", encoding="utf-8", dir=self.data_dir, prefix="focus-", suffix=".tmp", delete=False
|
|
94
|
+
)
|
|
95
|
+
try:
|
|
96
|
+
json.dump(payload, handle, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
97
|
+
handle.flush()
|
|
98
|
+
os.fsync(handle.fileno())
|
|
99
|
+
handle.close()
|
|
100
|
+
os.replace(handle.name, self.focus_path)
|
|
101
|
+
finally:
|
|
102
|
+
if not handle.closed:
|
|
103
|
+
handle.close()
|
|
104
|
+
if os.path.exists(handle.name):
|
|
105
|
+
os.unlink(handle.name)
|
|
106
|
+
|
|
107
|
+
def initialize(self) -> None:
|
|
108
|
+
"""Materialize the three durable stores without inventing experience."""
|
|
109
|
+
self.core.initialize()
|
|
110
|
+
self._save_focus()
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _result(value: Mapping[str, Any]) -> Dict[str, Any]:
|
|
114
|
+
return {"schema": BRIDGE_SCHEMA, "result": dict(value)}
|
|
115
|
+
|
|
116
|
+
def begin(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
117
|
+
result = self.runtime.begin(_object(payload))
|
|
118
|
+
self._save_focus()
|
|
119
|
+
return self._result(result)
|
|
120
|
+
|
|
121
|
+
def query(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
122
|
+
request = _object(payload)
|
|
123
|
+
task_id = _text(request.get("task_id"), "task_id")
|
|
124
|
+
intent = _text(request.get("intent"), "intent")
|
|
125
|
+
scopes = _string_list(request.get("scopes"), "scopes")
|
|
126
|
+
kinds = _string_list(request.get("kinds"), "kinds")
|
|
127
|
+
limit = request.get("limit", 3)
|
|
128
|
+
if not isinstance(limit, int):
|
|
129
|
+
raise BridgeValidationError("limit must be an integer")
|
|
130
|
+
kwargs: Dict[str, Any] = {"intent": intent, "limit": limit}
|
|
131
|
+
if scopes is not None:
|
|
132
|
+
kwargs["scopes"] = scopes
|
|
133
|
+
if kinds is not None:
|
|
134
|
+
kwargs["kinds"] = kinds
|
|
135
|
+
result = self.runtime.query(task_id, **kwargs)
|
|
136
|
+
self._save_focus()
|
|
137
|
+
return self._result(result)
|
|
138
|
+
|
|
139
|
+
def observe(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
140
|
+
request = _object(payload)
|
|
141
|
+
result = self.runtime.observe(
|
|
142
|
+
_text(request.get("task_id"), "task_id"), _object(request.get("event"), "event")
|
|
143
|
+
)
|
|
144
|
+
return self._result(result)
|
|
145
|
+
|
|
146
|
+
def check_action(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
147
|
+
request = _object(payload)
|
|
148
|
+
result = self.runtime.check_action(
|
|
149
|
+
_text(request.get("task_id"), "task_id"), _object(request.get("action"), "action")
|
|
150
|
+
)
|
|
151
|
+
return self._result(result)
|
|
152
|
+
|
|
153
|
+
def finish(self, payload: Mapping[str, Any]) -> Dict[str, Any]:
|
|
154
|
+
result = self.runtime.finish(_object(payload))
|
|
155
|
+
self._save_focus()
|
|
156
|
+
return self._result(result)
|
|
157
|
+
|
|
158
|
+
def status(self) -> Dict[str, Any]:
|
|
159
|
+
return {
|
|
160
|
+
"schema": BRIDGE_SCHEMA,
|
|
161
|
+
"data_dir": str(self.data_dir),
|
|
162
|
+
"active_task_count": len(self.runtime.export_focus()),
|
|
163
|
+
"core": self.core.status(),
|
|
164
|
+
"memory": self.memory.status(),
|
|
165
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Explicit host setup helpers for CORDIS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
BEGIN_MARKER = "# BEGIN CORDIS MANAGED MCP"
|
|
15
|
+
END_MARKER = "# END CORDIS MANAGED MCP"
|
|
16
|
+
PROTOCOL_BEGIN = "<!-- BEGIN CORDIS MANAGED PROTOCOL -->"
|
|
17
|
+
PROTOCOL_END = "<!-- END CORDIS MANAGED PROTOCOL -->"
|
|
18
|
+
PROTOCOL = """<!-- BEGIN CORDIS MANAGED PROTOCOL -->
|
|
19
|
+
## CORDIS cognitive loop
|
|
20
|
+
|
|
21
|
+
Use the CORDIS MCP tools for substantive tasks that can produce observable work or reusable learning.
|
|
22
|
+
|
|
23
|
+
1. Call `cordis_begin` before substantive work and follow the returned cognitive context and control signal.
|
|
24
|
+
2. Call `cordis_observe` only for meaningful plans, tool results, tests, artifacts, or errors; do not log routine chatter.
|
|
25
|
+
3. Call `cordis_check_action` before destructive or potentially drifting actions.
|
|
26
|
+
4. Call `cordis_finish` exactly once with observable evidence and an honest outcome before declaring the task complete.
|
|
27
|
+
5. Skip the loop for greetings, casual conversation, and trivial read-only questions.
|
|
28
|
+
<!-- END CORDIS MANAGED PROTOCOL -->"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SetupError(ValueError):
|
|
32
|
+
"""Raised when automatic host configuration would be unsafe."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _toml_string(value: str) -> str:
|
|
36
|
+
return json.dumps(value, ensure_ascii=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def codex_config_path() -> Path:
|
|
40
|
+
root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
41
|
+
return root / "config.toml"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def host_state_dir(host: str) -> Path:
|
|
45
|
+
return (Path.home() / ".cordis" / "hosts" / host).resolve()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _server_command(state_dir: Path) -> list[str]:
|
|
49
|
+
return [sys.executable, "-m", "cordis_bridge.mcp_server", "--data-dir", str(state_dir)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _host_executable(name: str) -> str:
|
|
53
|
+
candidates = [name]
|
|
54
|
+
if os.name == "nt":
|
|
55
|
+
candidates = [f"{name}.exe", f"{name}.cmd", f"{name}.bat", name]
|
|
56
|
+
for candidate in candidates:
|
|
57
|
+
found = shutil.which(candidate)
|
|
58
|
+
if found and not found.lower().endswith(".ps1"):
|
|
59
|
+
return found
|
|
60
|
+
raise SetupError(f"{name} is not installed or is not available on PATH")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(command: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]:
|
|
64
|
+
return subprocess.run(
|
|
65
|
+
command,
|
|
66
|
+
input=input_text,
|
|
67
|
+
text=True,
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
errors="replace",
|
|
70
|
+
capture_output=True,
|
|
71
|
+
check=False,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _install_protocol(path: Path) -> tuple[bool, str | None]:
|
|
76
|
+
path = path.expanduser().resolve()
|
|
77
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
79
|
+
if PROTOCOL_BEGIN in original:
|
|
80
|
+
start = original.index(PROTOCOL_BEGIN)
|
|
81
|
+
end_marker = original.find(PROTOCOL_END, start)
|
|
82
|
+
if end_marker < 0:
|
|
83
|
+
raise SetupError(f"{path} contains an incomplete CORDIS protocol block")
|
|
84
|
+
end = end_marker + len(PROTOCOL_END)
|
|
85
|
+
updated = original[:start] + PROTOCOL + original[end:]
|
|
86
|
+
else:
|
|
87
|
+
separator = "" if not original else ("\n" if original.endswith("\n") else "\n\n")
|
|
88
|
+
updated = original + separator + PROTOCOL + "\n"
|
|
89
|
+
changed = updated != original
|
|
90
|
+
backup = None
|
|
91
|
+
if changed and path.exists():
|
|
92
|
+
backup_path = path.with_suffix(path.suffix + ".cordis-backup")
|
|
93
|
+
shutil.copy2(path, backup_path)
|
|
94
|
+
backup = str(backup_path)
|
|
95
|
+
if changed:
|
|
96
|
+
path.write_text(updated, encoding="utf-8")
|
|
97
|
+
return changed, backup
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _protocol_path(host: str) -> Path:
|
|
101
|
+
if host == "codex":
|
|
102
|
+
root = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
|
|
103
|
+
override = root / "AGENTS.override.md"
|
|
104
|
+
return override if override.exists() else root / "AGENTS.md"
|
|
105
|
+
if host == "claude-code":
|
|
106
|
+
return Path.home() / ".claude" / "CLAUDE.md"
|
|
107
|
+
if host == "opencode":
|
|
108
|
+
return Path.home() / ".config" / "opencode" / "AGENTS.md"
|
|
109
|
+
if host == "hermes":
|
|
110
|
+
return Path.home() / ".hermes" / "AGENTS.md"
|
|
111
|
+
raise SetupError(f"unsupported host: {host}")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _with_protocol(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
115
|
+
path = _protocol_path(result["host"])
|
|
116
|
+
changed, backup = _install_protocol(path)
|
|
117
|
+
result["protocol_path"] = str(path.resolve())
|
|
118
|
+
result["protocol_changed"] = changed
|
|
119
|
+
result["protocol_backup_path"] = backup
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _result(host: str, state_dir: Path, **values: Any) -> Dict[str, Any]:
|
|
124
|
+
return {
|
|
125
|
+
"host": host,
|
|
126
|
+
"configured": True,
|
|
127
|
+
"state_dir": str(state_dir),
|
|
128
|
+
"state_policy": "one writer-safe state directory per host; projects remain isolated by project_id",
|
|
129
|
+
**values,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def setup_codex(config_path: str | Path | None = None, state_dir: str | Path | None = None) -> Dict[str, Any]:
|
|
134
|
+
"""Install a managed stdio MCP block in the user's Codex config."""
|
|
135
|
+
path = Path(config_path) if config_path is not None else codex_config_path()
|
|
136
|
+
path = path.expanduser().resolve()
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
139
|
+
state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("codex")
|
|
140
|
+
|
|
141
|
+
if "[mcp_servers.cordis]" in original and BEGIN_MARKER not in original:
|
|
142
|
+
raise SetupError(
|
|
143
|
+
f"{path} already contains an unmanaged [mcp_servers.cordis] section; "
|
|
144
|
+
"remove or rename it before running setup"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
block = "\n".join(
|
|
148
|
+
(
|
|
149
|
+
BEGIN_MARKER,
|
|
150
|
+
"[mcp_servers.cordis]",
|
|
151
|
+
f"command = {_toml_string(sys.executable)}",
|
|
152
|
+
f"args = [\"-m\", \"cordis_bridge.mcp_server\", \"--data-dir\", {_toml_string(str(state))}]",
|
|
153
|
+
"startup_timeout_sec = 30",
|
|
154
|
+
END_MARKER,
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if BEGIN_MARKER in original:
|
|
159
|
+
start = original.index(BEGIN_MARKER)
|
|
160
|
+
end_marker = original.find(END_MARKER, start)
|
|
161
|
+
if end_marker < 0:
|
|
162
|
+
raise SetupError(f"{path} contains an incomplete CORDIS managed block")
|
|
163
|
+
end = end_marker + len(END_MARKER)
|
|
164
|
+
updated = original[:start] + block + original[end:]
|
|
165
|
+
else:
|
|
166
|
+
separator = "" if not original else ("\n" if original.endswith("\n") else "\n\n")
|
|
167
|
+
updated = original + separator + block + "\n"
|
|
168
|
+
|
|
169
|
+
changed = updated != original
|
|
170
|
+
backup = None
|
|
171
|
+
if changed and path.exists():
|
|
172
|
+
backup = path.with_suffix(path.suffix + ".cordis-backup")
|
|
173
|
+
shutil.copy2(path, backup)
|
|
174
|
+
if changed:
|
|
175
|
+
path.write_text(updated, encoding="utf-8")
|
|
176
|
+
|
|
177
|
+
return _with_protocol(_result(
|
|
178
|
+
"codex",
|
|
179
|
+
state,
|
|
180
|
+
changed=changed,
|
|
181
|
+
config_path=str(path),
|
|
182
|
+
backup_path=str(backup) if backup else None,
|
|
183
|
+
restart_required=True,
|
|
184
|
+
))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def setup_claude_code(state_dir: str | Path | None = None) -> Dict[str, Any]:
|
|
188
|
+
state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("claude-code")
|
|
189
|
+
executable = _host_executable("claude")
|
|
190
|
+
existing = _run([executable, "mcp", "get", "cordis"])
|
|
191
|
+
if existing.returncode == 0:
|
|
192
|
+
if str(state) not in existing.stdout or sys.executable not in existing.stdout:
|
|
193
|
+
raise SetupError("Claude Code already has a different MCP server named cordis")
|
|
194
|
+
return _with_protocol(_result("claude-code", state, changed=False, verified=True, restart_required=True))
|
|
195
|
+
command = [executable, "mcp", "add", "--scope", "user", "cordis", "--", *_server_command(state)]
|
|
196
|
+
added = _run(command)
|
|
197
|
+
if added.returncode != 0:
|
|
198
|
+
raise SetupError(f"Claude Code rejected CORDIS MCP setup: {(added.stderr or added.stdout).strip()}")
|
|
199
|
+
verified = _run([executable, "mcp", "get", "cordis"])
|
|
200
|
+
if verified.returncode != 0:
|
|
201
|
+
raise SetupError("Claude Code saved CORDIS but could not verify the server")
|
|
202
|
+
return _with_protocol(_result("claude-code", state, changed=True, verified=True, restart_required=True))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def setup_hermes(state_dir: str | Path | None = None) -> Dict[str, Any]:
|
|
206
|
+
state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("hermes")
|
|
207
|
+
executable = _host_executable("hermes")
|
|
208
|
+
existing = _run([executable, "mcp", "test", "cordis"])
|
|
209
|
+
if existing.returncode == 0 and "Connected" in existing.stdout:
|
|
210
|
+
return _with_protocol(_result("hermes", state, changed=False, verified=True, restart_required=True))
|
|
211
|
+
command = [
|
|
212
|
+
executable,
|
|
213
|
+
"mcp",
|
|
214
|
+
"add",
|
|
215
|
+
"cordis",
|
|
216
|
+
"--command",
|
|
217
|
+
sys.executable,
|
|
218
|
+
"--args",
|
|
219
|
+
"-m",
|
|
220
|
+
"cordis_bridge.mcp_server",
|
|
221
|
+
"--data-dir",
|
|
222
|
+
str(state),
|
|
223
|
+
]
|
|
224
|
+
added = _run(command, input_text="y\n")
|
|
225
|
+
if added.returncode != 0 or "Saved 'cordis'" not in added.stdout:
|
|
226
|
+
raise SetupError(f"Hermes rejected CORDIS MCP setup: {(added.stderr or added.stdout).strip()}")
|
|
227
|
+
verified = _run([executable, "mcp", "test", "cordis"])
|
|
228
|
+
if verified.returncode != 0:
|
|
229
|
+
raise SetupError(f"Hermes saved CORDIS but connection verification failed: {verified.stdout.strip()}")
|
|
230
|
+
return _with_protocol(_result("hermes", state, changed=True, verified=True, restart_required=True))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def opencode_config_path() -> Path:
|
|
234
|
+
return (Path.home() / ".config" / "opencode" / "opencode.json").resolve()
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def setup_opencode(
|
|
238
|
+
config_path: str | Path | None = None, state_dir: str | Path | None = None
|
|
239
|
+
) -> Dict[str, Any]:
|
|
240
|
+
path = Path(config_path).expanduser().resolve() if config_path else opencode_config_path()
|
|
241
|
+
state = Path(state_dir).expanduser().resolve() if state_dir else host_state_dir("opencode")
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
original_text = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
244
|
+
try:
|
|
245
|
+
config = json.loads(original_text) if original_text else {"$schema": "https://opencode.ai/config.json"}
|
|
246
|
+
except json.JSONDecodeError as error:
|
|
247
|
+
raise SetupError(f"{path} is not strict JSON; CORDIS will not rewrite JSONC automatically") from error
|
|
248
|
+
if not isinstance(config, dict):
|
|
249
|
+
raise SetupError(f"{path} must contain one JSON object")
|
|
250
|
+
mcp = config.setdefault("mcp", {})
|
|
251
|
+
if not isinstance(mcp, dict):
|
|
252
|
+
raise SetupError(f"{path} field 'mcp' must be an object")
|
|
253
|
+
desired = {"type": "local", "command": _server_command(state), "enabled": True, "timeout": 30000}
|
|
254
|
+
existing = mcp.get("cordis")
|
|
255
|
+
if existing is not None and existing != desired:
|
|
256
|
+
raise SetupError(f"{path} already contains a different mcp.cordis configuration")
|
|
257
|
+
mcp["cordis"] = desired
|
|
258
|
+
updated = json.dumps(config, ensure_ascii=False, indent=2) + "\n"
|
|
259
|
+
changed = updated != original_text
|
|
260
|
+
backup = None
|
|
261
|
+
if changed and path.exists():
|
|
262
|
+
backup = path.with_suffix(path.suffix + ".cordis-backup")
|
|
263
|
+
shutil.copy2(path, backup)
|
|
264
|
+
if changed:
|
|
265
|
+
path.write_text(updated, encoding="utf-8")
|
|
266
|
+
return _with_protocol(_result(
|
|
267
|
+
"opencode",
|
|
268
|
+
state,
|
|
269
|
+
changed=changed,
|
|
270
|
+
verified=True,
|
|
271
|
+
config_path=str(path),
|
|
272
|
+
backup_path=str(backup) if backup else None,
|
|
273
|
+
restart_required=True,
|
|
274
|
+
))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cordis-bridge
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: CLI and optional MCP bridge for the Cordis cognitive runtime.
|
|
5
|
+
Author: Cordis Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: cordis-core<0.2,>=0.1.1
|
|
16
|
+
Requires-Dist: cordis-memory<0.2,>=0.1.1
|
|
17
|
+
Requires-Dist: cordis-runtime<0.2,>=0.1.1
|
|
18
|
+
Provides-Extra: mcp
|
|
19
|
+
Requires-Dist: mcp<2,>=1.26; extra == "mcp"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# Cordis Bridge v0.1
|
|
23
|
+
|
|
24
|
+
`cordis-bridge` exposes the existing CORDIS cognitive loop to a real host.
|
|
25
|
+
It has no model, planner, HTTP server, or execution permission of its own.
|
|
26
|
+
|
|
27
|
+
The bridge provides one shared Service behind two transports:
|
|
28
|
+
|
|
29
|
+
- a JSON-only CLI for local hosts and scripts;
|
|
30
|
+
- an optional stdio MCP server for agent hosts.
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
host -> begin -> compact context + control signal
|
|
34
|
+
host -> observe / check-action -> workflow evidence
|
|
35
|
+
host -> finish -> Core feedback + Memory episode
|
|
36
|
+
later host -> begin -> changed cognition
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
By default, durable state lives in `.cordis/` below the current directory:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
.cordis/state.json Core learning state
|
|
43
|
+
.cordis/cognition.db Memory and relationship graph
|
|
44
|
+
.cordis/focus.json Active host-task focus for CLI/MCP process recovery
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## CLI
|
|
48
|
+
|
|
49
|
+
Every command reads one JSON object from a file or standard input and emits
|
|
50
|
+
one JSON object. The input file defaults to `-` (standard input).
|
|
51
|
+
|
|
52
|
+
```bat
|
|
53
|
+
cordis begin task.json
|
|
54
|
+
cordis query query.json
|
|
55
|
+
cordis observe event.json
|
|
56
|
+
cordis check-action action.json
|
|
57
|
+
cordis finish feedback.json
|
|
58
|
+
cordis status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `--data-dir PATH` before the command to keep a project-specific state
|
|
62
|
+
directory elsewhere.
|
|
63
|
+
|
|
64
|
+
## MCP
|
|
65
|
+
|
|
66
|
+
Install the optional transport dependency and run:
|
|
67
|
+
|
|
68
|
+
```bat
|
|
69
|
+
pip install "cordis-bridge[mcp]"
|
|
70
|
+
cordis-mcp --data-dir .cordis
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The server exposes `cordis_begin`, `cordis_query`, `cordis_observe`,
|
|
74
|
+
`cordis_check_action`, `cordis_finish`, and `cordis_status`. Each tool takes
|
|
75
|
+
the same JSON contract as the matching CLI command.
|
|
76
|
+
|
|
77
|
+
## Concurrency boundary
|
|
78
|
+
|
|
79
|
+
CORDIS v0.1 assumes one mutating host per state directory. Run one MCP server
|
|
80
|
+
per `.cordis` directory and keep CLI mutations for that directory sequential.
|
|
81
|
+
The Core JSON state and Bridge focus snapshot are deliberately small local
|
|
82
|
+
stores; multi-writer coordination is not part of this release.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/cordis_bridge/__init__.py
|
|
5
|
+
src/cordis_bridge/cli.py
|
|
6
|
+
src/cordis_bridge/mcp_server.py
|
|
7
|
+
src/cordis_bridge/service.py
|
|
8
|
+
src/cordis_bridge/setup.py
|
|
9
|
+
src/cordis_bridge.egg-info/PKG-INFO
|
|
10
|
+
src/cordis_bridge.egg-info/SOURCES.txt
|
|
11
|
+
src/cordis_bridge.egg-info/dependency_links.txt
|
|
12
|
+
src/cordis_bridge.egg-info/entry_points.txt
|
|
13
|
+
src/cordis_bridge.egg-info/requires.txt
|
|
14
|
+
src/cordis_bridge.egg-info/top_level.txt
|
|
15
|
+
tests/test_cli.py
|
|
16
|
+
tests/test_mcp.py
|
|
17
|
+
tests/test_service.py
|
|
18
|
+
tests/test_setup.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cordis_bridge
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ROOT = Path(__file__).resolve().parents[2]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CordisCliTests(unittest.TestCase):
|
|
16
|
+
def setUp(self) -> None:
|
|
17
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
18
|
+
self.data_dir = Path(self.tempdir.name) / ".cordis"
|
|
19
|
+
self.env = dict(os.environ)
|
|
20
|
+
source_paths = [
|
|
21
|
+
str(ROOT / "cordis-bridge" / "src"),
|
|
22
|
+
str(ROOT / "cordis-runtime" / "src"),
|
|
23
|
+
str(ROOT / "cordis-memory" / "src"),
|
|
24
|
+
str(ROOT / "cordis-core" / "src"),
|
|
25
|
+
]
|
|
26
|
+
self.env["PYTHONPATH"] = os.pathsep.join(source_paths + [self.env.get("PYTHONPATH", "")])
|
|
27
|
+
|
|
28
|
+
def tearDown(self) -> None:
|
|
29
|
+
self.tempdir.cleanup()
|
|
30
|
+
|
|
31
|
+
def call(self, command: str, payload: dict | None = None) -> tuple[int, dict]:
|
|
32
|
+
args = [sys.executable, "-m", "cordis_bridge.cli", "--data-dir", str(self.data_dir), command]
|
|
33
|
+
completed = subprocess.run(
|
|
34
|
+
args,
|
|
35
|
+
input=json.dumps(payload) if payload is not None else None,
|
|
36
|
+
text=True,
|
|
37
|
+
capture_output=True,
|
|
38
|
+
env=self.env,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
return completed.returncode, json.loads(completed.stdout)
|
|
42
|
+
|
|
43
|
+
@staticmethod
|
|
44
|
+
def task() -> dict:
|
|
45
|
+
return {
|
|
46
|
+
"task": {
|
|
47
|
+
"goal": "Start an HTTP adapter in a clean environment",
|
|
48
|
+
"domain": "software",
|
|
49
|
+
"project_id": "cli-test",
|
|
50
|
+
"strategy_id": "import_runtime_dependencies",
|
|
51
|
+
"stakes": "medium",
|
|
52
|
+
},
|
|
53
|
+
"complexity": 0.4,
|
|
54
|
+
"acceptance_evidence": ["clean-environment test passes"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def test_cli_closes_the_learning_loop_across_processes(self) -> None:
|
|
58
|
+
code, first = self.call("begin", self.task())
|
|
59
|
+
self.assertEqual(code, 0)
|
|
60
|
+
task_id = first["result"]["task_id"]
|
|
61
|
+
|
|
62
|
+
code, finished = self.call(
|
|
63
|
+
"finish",
|
|
64
|
+
{
|
|
65
|
+
"task_id": task_id,
|
|
66
|
+
"outcome": "failure",
|
|
67
|
+
"attribution": "strategy",
|
|
68
|
+
"lesson": "Avoid import-time runtime initialization in reusable modules.",
|
|
69
|
+
"evidence": [{"kind": "strategy", "passed": False, "summary": "clean import failed"}],
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
self.assertEqual(code, 0)
|
|
73
|
+
self.assertIn("strategy_outcome", finished["result"]["learning"]["state_updates"])
|
|
74
|
+
|
|
75
|
+
code, second = self.call("begin", self.task())
|
|
76
|
+
self.assertEqual(code, 0)
|
|
77
|
+
self.assertEqual(second["result"]["cognitive_ir"]["strategy"]["status"], "avoid_until_revalidated")
|
|
78
|
+
|
|
79
|
+
def test_cli_init_materializes_three_local_state_stores(self) -> None:
|
|
80
|
+
code, result = self.call("init")
|
|
81
|
+
self.assertEqual(code, 0)
|
|
82
|
+
self.assertTrue(result["initialized"])
|
|
83
|
+
self.assertEqual(result["data_dir"], str(self.data_dir.resolve()))
|
|
84
|
+
self.assertTrue((self.data_dir / "state.json").is_file())
|
|
85
|
+
self.assertTrue((self.data_dir / "cognition.db").is_file())
|
|
86
|
+
self.assertTrue((self.data_dir / "focus.json").is_file())
|
|
87
|
+
|
|
88
|
+
def test_cli_uses_json_error_contract(self) -> None:
|
|
89
|
+
code, result = self.call("finish", {"task_id": "missing", "outcome": "failure", "evidence": []})
|
|
90
|
+
self.assertEqual(code, 2)
|
|
91
|
+
self.assertEqual(result["error"]["type"], "validation_error")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
if importlib.util.find_spec("mcp"):
|
|
11
|
+
from mcp import ClientSession
|
|
12
|
+
from mcp.client.stdio import StdioServerParameters, stdio_client
|
|
13
|
+
else: # Keep the source suite usable without the optional transport dependency.
|
|
14
|
+
ClientSession = StdioServerParameters = stdio_client = None
|
|
15
|
+
|
|
16
|
+
from cordis_bridge.mcp_server import create_server
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@unittest.skipUnless(importlib.util.find_spec("mcp"), "optional mcp package is not installed")
|
|
20
|
+
class CordisMcpTests(unittest.TestCase):
|
|
21
|
+
def test_server_registers_the_six_bridge_tools(self) -> None:
|
|
22
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
23
|
+
server = create_server(directory)
|
|
24
|
+
try:
|
|
25
|
+
names = {tool.name for tool in asyncio.run(server.list_tools())}
|
|
26
|
+
finally:
|
|
27
|
+
server.cordis_service.close()
|
|
28
|
+
self.assertEqual(
|
|
29
|
+
names,
|
|
30
|
+
{
|
|
31
|
+
"cordis_begin",
|
|
32
|
+
"cordis_query",
|
|
33
|
+
"cordis_observe",
|
|
34
|
+
"cordis_check_action",
|
|
35
|
+
"cordis_finish",
|
|
36
|
+
"cordis_status",
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@unittest.skipUnless(importlib.util.find_spec("mcp"), "optional mcp package is not installed")
|
|
42
|
+
class CordisMcpStdioTests(unittest.IsolatedAsyncioTestCase):
|
|
43
|
+
async def test_stdio_server_handles_a_real_begin_request(self) -> None:
|
|
44
|
+
with tempfile.TemporaryDirectory() as directory:
|
|
45
|
+
parameters = StdioServerParameters(
|
|
46
|
+
command=sys.executable,
|
|
47
|
+
args=["-m", "cordis_bridge.mcp_server", "--data-dir", str(Path(directory) / "state")],
|
|
48
|
+
)
|
|
49
|
+
async with stdio_client(parameters) as (read, write):
|
|
50
|
+
async with ClientSession(read, write) as session:
|
|
51
|
+
await session.initialize()
|
|
52
|
+
listed = await session.list_tools()
|
|
53
|
+
self.assertIn("cordis_begin", {tool.name for tool in listed.tools})
|
|
54
|
+
result = await session.call_tool(
|
|
55
|
+
"cordis_begin",
|
|
56
|
+
{
|
|
57
|
+
"payload": {
|
|
58
|
+
"task": {
|
|
59
|
+
"goal": "Verify the MCP transport",
|
|
60
|
+
"domain": "software",
|
|
61
|
+
"project_id": "mcp-test",
|
|
62
|
+
"strategy_id": "inspect",
|
|
63
|
+
"stakes": "low",
|
|
64
|
+
},
|
|
65
|
+
"complexity": 0.1,
|
|
66
|
+
"acceptance_evidence": ["MCP response is returned"],
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
self.assertFalse(result.isError)
|
|
71
|
+
self.assertIn("cordis.runtime.v1", result.content[0].text)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from cordis_bridge import CordisService
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CordisServiceTests(unittest.TestCase):
|
|
11
|
+
def setUp(self) -> None:
|
|
12
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
13
|
+
self.data_dir = Path(self.tempdir.name) / ".cordis"
|
|
14
|
+
|
|
15
|
+
def tearDown(self) -> None:
|
|
16
|
+
self.tempdir.cleanup()
|
|
17
|
+
|
|
18
|
+
@staticmethod
|
|
19
|
+
def task() -> dict:
|
|
20
|
+
return {
|
|
21
|
+
"task": {
|
|
22
|
+
"goal": "Inspect the login integration test logs",
|
|
23
|
+
"domain": "software",
|
|
24
|
+
"project_id": "bridge-test",
|
|
25
|
+
"strategy_id": "inspect_logs",
|
|
26
|
+
"stakes": "medium",
|
|
27
|
+
},
|
|
28
|
+
"complexity": 0.4,
|
|
29
|
+
"current_step": "Inspect the failing integration test",
|
|
30
|
+
"constraints": ["Do not change the database schema"],
|
|
31
|
+
"acceptance_evidence": ["login integration test passes"],
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def test_service_persists_active_focus_for_a_new_process(self) -> None:
|
|
35
|
+
with CordisService(self.data_dir) as first:
|
|
36
|
+
begin = first.begin(self.task())
|
|
37
|
+
task_id = begin["result"]["task_id"]
|
|
38
|
+
|
|
39
|
+
with CordisService(self.data_dir) as second:
|
|
40
|
+
action = second.check_action(
|
|
41
|
+
{
|
|
42
|
+
"task_id": task_id,
|
|
43
|
+
"action": {"description": "Inspect login logs", "purpose": "Find test failure"},
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
self.assertTrue(action["result"]["aligned"])
|
|
47
|
+
second.observe(
|
|
48
|
+
{
|
|
49
|
+
"task_id": task_id,
|
|
50
|
+
"event": {
|
|
51
|
+
"event_type": "tool_failed",
|
|
52
|
+
"tool": "shell",
|
|
53
|
+
"subject": "integration test",
|
|
54
|
+
"expected": "tests pass",
|
|
55
|
+
"actual": "exit code 1",
|
|
56
|
+
"error_class": "test_failure",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
second.finish(
|
|
61
|
+
{
|
|
62
|
+
"task_id": task_id,
|
|
63
|
+
"outcome": "failure",
|
|
64
|
+
"attribution": "strategy",
|
|
65
|
+
"lesson": "Inspect configuration before relying only on logs.",
|
|
66
|
+
"evidence": [{"kind": "strategy", "passed": False, "summary": "log-only approach failed"}],
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
with CordisService(self.data_dir) as third:
|
|
71
|
+
changed = third.begin(self.task())
|
|
72
|
+
context = changed["result"]
|
|
73
|
+
self.assertEqual(context["cognitive_ir"]["strategy"]["status"], "avoid_until_revalidated")
|
|
74
|
+
self.assertEqual(context["control"]["mode"], "high_intervention")
|
|
75
|
+
self.assertIn("repeat_failed_strategy:inspect_logs", context["model_context"])
|
|
76
|
+
self.assertEqual(third.status()["active_task_count"], 1)
|
|
77
|
+
|
|
78
|
+
def test_status_combines_core_memory_and_active_tasks(self) -> None:
|
|
79
|
+
with CordisService(self.data_dir) as service:
|
|
80
|
+
service.begin(self.task())
|
|
81
|
+
status = service.status()
|
|
82
|
+
self.assertEqual(status["schema"], "cordis.bridge.v1")
|
|
83
|
+
self.assertEqual(status["active_task_count"], 1)
|
|
84
|
+
self.assertEqual(status["core"]["task_count"], 1)
|
|
85
|
+
self.assertIn("memory_counts", status["memory"])
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from unittest.mock import patch
|
|
7
|
+
|
|
8
|
+
from cordis_bridge.setup import (
|
|
9
|
+
BEGIN_MARKER,
|
|
10
|
+
PROTOCOL_BEGIN,
|
|
11
|
+
SetupError,
|
|
12
|
+
setup_claude_code,
|
|
13
|
+
setup_codex,
|
|
14
|
+
setup_hermes,
|
|
15
|
+
setup_opencode,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CordisCodexSetupTests(unittest.TestCase):
|
|
20
|
+
def setUp(self) -> None:
|
|
21
|
+
self.tempdir = tempfile.TemporaryDirectory()
|
|
22
|
+
self.config = Path(self.tempdir.name) / "config.toml"
|
|
23
|
+
|
|
24
|
+
def tearDown(self) -> None:
|
|
25
|
+
self.tempdir.cleanup()
|
|
26
|
+
|
|
27
|
+
def test_setup_adds_managed_block_and_preserves_existing_config(self) -> None:
|
|
28
|
+
self.config.write_text('model = "example"\n', encoding="utf-8")
|
|
29
|
+
with patch("cordis_bridge.setup.sys.executable", r"C:\Cordis\python.exe"):
|
|
30
|
+
result = setup_codex(self.config, Path(self.tempdir.name) / "state")
|
|
31
|
+
content = self.config.read_text(encoding="utf-8")
|
|
32
|
+
self.assertIn('model = "example"', content)
|
|
33
|
+
self.assertIn(BEGIN_MARKER, content)
|
|
34
|
+
self.assertIn('[mcp_servers.cordis]', content)
|
|
35
|
+
self.assertIn('command = "C:\\\\Cordis\\\\python.exe"', content)
|
|
36
|
+
self.assertTrue(result["changed"])
|
|
37
|
+
self.assertTrue(Path(result["backup_path"]).is_file())
|
|
38
|
+
|
|
39
|
+
def test_setup_is_idempotent(self) -> None:
|
|
40
|
+
first = setup_codex(self.config)
|
|
41
|
+
second = setup_codex(self.config)
|
|
42
|
+
self.assertTrue(first["changed"])
|
|
43
|
+
self.assertFalse(second["changed"])
|
|
44
|
+
self.assertEqual(self.config.read_text(encoding="utf-8").count(BEGIN_MARKER), 1)
|
|
45
|
+
|
|
46
|
+
def test_setup_refuses_unmanaged_existing_server(self) -> None:
|
|
47
|
+
self.config.write_text("[mcp_servers.cordis]\ncommand = 'custom'\n", encoding="utf-8")
|
|
48
|
+
with self.assertRaises(SetupError):
|
|
49
|
+
setup_codex(self.config)
|
|
50
|
+
|
|
51
|
+
@patch("cordis_bridge.setup._protocol_path")
|
|
52
|
+
def test_setup_installs_managed_usage_protocol(self, protocol_path) -> None:
|
|
53
|
+
protocol = Path(self.tempdir.name) / "AGENTS.md"
|
|
54
|
+
protocol_path.return_value = protocol
|
|
55
|
+
setup_codex(self.config, Path(self.tempdir.name) / "state")
|
|
56
|
+
first = protocol.read_text(encoding="utf-8")
|
|
57
|
+
self.assertIn(PROTOCOL_BEGIN, first)
|
|
58
|
+
self.assertIn("cordis_begin", first)
|
|
59
|
+
setup_codex(self.config, Path(self.tempdir.name) / "state")
|
|
60
|
+
self.assertEqual(protocol.read_text(encoding="utf-8").count(PROTOCOL_BEGIN), 1)
|
|
61
|
+
|
|
62
|
+
def test_opencode_setup_merges_official_local_mcp_schema(self) -> None:
|
|
63
|
+
self.config.write_text('{"model": "example"}\n', encoding="utf-8")
|
|
64
|
+
state = Path(self.tempdir.name) / "opencode-state"
|
|
65
|
+
with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "AGENTS.md"):
|
|
66
|
+
result = setup_opencode(self.config, state)
|
|
67
|
+
value = __import__("json").loads(self.config.read_text(encoding="utf-8"))
|
|
68
|
+
self.assertEqual(value["model"], "example")
|
|
69
|
+
self.assertEqual(value["mcp"]["cordis"]["type"], "local")
|
|
70
|
+
self.assertEqual(value["mcp"]["cordis"]["command"][-1], str(state.resolve()))
|
|
71
|
+
self.assertTrue(value["mcp"]["cordis"]["enabled"])
|
|
72
|
+
self.assertTrue(result["changed"])
|
|
73
|
+
|
|
74
|
+
@patch("cordis_bridge.setup._host_executable", return_value="claude")
|
|
75
|
+
@patch("cordis_bridge.setup._run")
|
|
76
|
+
def test_claude_setup_uses_official_user_scope_cli(self, run, _host) -> None:
|
|
77
|
+
from subprocess import CompletedProcess
|
|
78
|
+
|
|
79
|
+
run.side_effect = [
|
|
80
|
+
CompletedProcess([], 1, "", "not found"),
|
|
81
|
+
CompletedProcess([], 0, "added", ""),
|
|
82
|
+
CompletedProcess([], 0, "connected", ""),
|
|
83
|
+
]
|
|
84
|
+
with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "CLAUDE.md"):
|
|
85
|
+
result = setup_claude_code(Path(self.tempdir.name) / "claude-state")
|
|
86
|
+
add = run.call_args_list[1].args[0]
|
|
87
|
+
self.assertEqual(add[:6], ["claude", "mcp", "add", "--scope", "user", "cordis"])
|
|
88
|
+
self.assertTrue(result["verified"])
|
|
89
|
+
|
|
90
|
+
@patch("cordis_bridge.setup._host_executable", return_value="hermes")
|
|
91
|
+
@patch("cordis_bridge.setup._run")
|
|
92
|
+
def test_hermes_setup_confirms_tools_and_verifies_connection(self, run, _host) -> None:
|
|
93
|
+
from subprocess import CompletedProcess
|
|
94
|
+
|
|
95
|
+
run.side_effect = [
|
|
96
|
+
CompletedProcess([], 1, "missing", ""),
|
|
97
|
+
CompletedProcess([], 0, "Saved 'cordis'", ""),
|
|
98
|
+
CompletedProcess([], 0, "Connected", ""),
|
|
99
|
+
]
|
|
100
|
+
with patch("cordis_bridge.setup._protocol_path", return_value=Path(self.tempdir.name) / "AGENTS.md"):
|
|
101
|
+
result = setup_hermes(Path(self.tempdir.name) / "hermes-state")
|
|
102
|
+
self.assertEqual(run.call_args_list[1].kwargs["input_text"], "y\n")
|
|
103
|
+
self.assertTrue(result["verified"])
|