unlegacy-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.
- unlegacy_cli-0.1.0/PKG-INFO +130 -0
- unlegacy_cli-0.1.0/README.md +111 -0
- unlegacy_cli-0.1.0/pyproject.toml +41 -0
- unlegacy_cli-0.1.0/setup.cfg +4 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/__init__.py +3 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/__main__.py +5 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/adapters/client_configs.py +153 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/adapters/mcp_http.py +136 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/adapters/skills.py +78 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/application/setup.py +158 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/assets/skills/unlegacy/SKILL.md +44 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/cli.py +404 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/domain/models.py +85 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli/domain/ports.py +40 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/PKG-INFO +130 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/SOURCES.txt +22 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/dependency_links.txt +1 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/entry_points.txt +2 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/requires.txt +3 -0
- unlegacy_cli-0.1.0/src/unlegacy_cli.egg-info/top_level.txt +1 -0
- unlegacy_cli-0.1.0/tests/test_cli.py +207 -0
- unlegacy_cli-0.1.0/tests/test_config_adapters.py +69 -0
- unlegacy_cli-0.1.0/tests/test_setup_use_case.py +99 -0
- unlegacy_cli-0.1.0/tests/test_skill_package.py +29 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: unlegacy-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unlegacy setup CLI for external coding agents
|
|
5
|
+
Author: Unlegacy
|
|
6
|
+
Project-URL: Homepage, https://app.unlegacy.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/unlegacy-ai/unlegacy-core
|
|
8
|
+
Keywords: unlegacy,mcp,cli,coding-agents
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Topic :: Software Development
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# unlegacy-cli
|
|
21
|
+
|
|
22
|
+
Installable CLI for connecting local coding agents to Unlegacy MCP.
|
|
23
|
+
|
|
24
|
+
Unlegacy generates repository documentation and knowledge graph data in the
|
|
25
|
+
platform. This CLI configures external coding agents so they can consume that
|
|
26
|
+
MCP surface.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Published package:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
uv tool install unlegacy-cli
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
or:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
pip install unlegacy-cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
After install, the command is saved on the user's machine as:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
unlegacy --help
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
From a checkout:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
pip install .
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
or:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
uv tool install .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
One-shot setup without preinstalling:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
uv tool run --from unlegacy-cli unlegacy setup --token mcp_live_xxx --endpoint https://app.unlegacy.ai/ai/mcp
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Distribution
|
|
67
|
+
|
|
68
|
+
Build the package artifacts:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
uv build
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The build creates `dist/unlegacy_cli-<version>.tar.gz` and
|
|
75
|
+
`dist/unlegacy_cli-<version>-py3-none-any.whl`. Publish those artifacts to the
|
|
76
|
+
Python package index used by Unlegacy clients.
|
|
77
|
+
|
|
78
|
+
## Setup
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
unlegacy setup
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The flow:
|
|
85
|
+
|
|
86
|
+
1. Shows the Unlegacy logo.
|
|
87
|
+
2. Prompts for the MCP token from the Unlegacy frontend.
|
|
88
|
+
3. Prompts for the MCP endpoint. Press Enter to use the local app endpoint: `http://localhost:5173/ai/mcp`.
|
|
89
|
+
4. Lets the user select supported clients.
|
|
90
|
+
5. Writes or merges MCP config.
|
|
91
|
+
6. Installs the packaged `unlegacy` skill.
|
|
92
|
+
7. Runs `unlegacy doctor`.
|
|
93
|
+
|
|
94
|
+
Non-interactive usage:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
UNLEGACY_MCP_TOKEN=mcp_live_xxx \
|
|
98
|
+
unlegacy setup --client claude-code --client codex --endpoint https://app.unlegacy.ai/ai/mcp
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
For local development:
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
UNLEGACY_MCP_TOKEN=mcp_live_xxx \
|
|
105
|
+
unlegacy setup --client claude-code --client codex --endpoint http://localhost:5173/ai/mcp
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Supported client values:
|
|
109
|
+
|
|
110
|
+
- `claude-code`
|
|
111
|
+
- `codex`
|
|
112
|
+
- `cursor`
|
|
113
|
+
- `vscode-copilot`
|
|
114
|
+
- `opencode`
|
|
115
|
+
- `all`
|
|
116
|
+
|
|
117
|
+
## Commands
|
|
118
|
+
|
|
119
|
+
```sh
|
|
120
|
+
unlegacy setup
|
|
121
|
+
unlegacy doctor
|
|
122
|
+
unlegacy install-skills --client codex
|
|
123
|
+
unlegacy status
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Development
|
|
127
|
+
|
|
128
|
+
```sh
|
|
129
|
+
uv run pytest
|
|
130
|
+
```
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# unlegacy-cli
|
|
2
|
+
|
|
3
|
+
Installable CLI for connecting local coding agents to Unlegacy MCP.
|
|
4
|
+
|
|
5
|
+
Unlegacy generates repository documentation and knowledge graph data in the
|
|
6
|
+
platform. This CLI configures external coding agents so they can consume that
|
|
7
|
+
MCP surface.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Published package:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
uv tool install unlegacy-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
pip install unlegacy-cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
After install, the command is saved on the user's machine as:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
unlegacy --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
From a checkout:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
pip install .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
or:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
uv tool install .
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
One-shot setup without preinstalling:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
uv tool run --from unlegacy-cli unlegacy setup --token mcp_live_xxx --endpoint https://app.unlegacy.ai/ai/mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Distribution
|
|
48
|
+
|
|
49
|
+
Build the package artifacts:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
uv build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The build creates `dist/unlegacy_cli-<version>.tar.gz` and
|
|
56
|
+
`dist/unlegacy_cli-<version>-py3-none-any.whl`. Publish those artifacts to the
|
|
57
|
+
Python package index used by Unlegacy clients.
|
|
58
|
+
|
|
59
|
+
## Setup
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
unlegacy setup
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The flow:
|
|
66
|
+
|
|
67
|
+
1. Shows the Unlegacy logo.
|
|
68
|
+
2. Prompts for the MCP token from the Unlegacy frontend.
|
|
69
|
+
3. Prompts for the MCP endpoint. Press Enter to use the local app endpoint: `http://localhost:5173/ai/mcp`.
|
|
70
|
+
4. Lets the user select supported clients.
|
|
71
|
+
5. Writes or merges MCP config.
|
|
72
|
+
6. Installs the packaged `unlegacy` skill.
|
|
73
|
+
7. Runs `unlegacy doctor`.
|
|
74
|
+
|
|
75
|
+
Non-interactive usage:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
UNLEGACY_MCP_TOKEN=mcp_live_xxx \
|
|
79
|
+
unlegacy setup --client claude-code --client codex --endpoint https://app.unlegacy.ai/ai/mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
For local development:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
UNLEGACY_MCP_TOKEN=mcp_live_xxx \
|
|
86
|
+
unlegacy setup --client claude-code --client codex --endpoint http://localhost:5173/ai/mcp
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Supported client values:
|
|
90
|
+
|
|
91
|
+
- `claude-code`
|
|
92
|
+
- `codex`
|
|
93
|
+
- `cursor`
|
|
94
|
+
- `vscode-copilot`
|
|
95
|
+
- `opencode`
|
|
96
|
+
- `all`
|
|
97
|
+
|
|
98
|
+
## Commands
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
unlegacy setup
|
|
102
|
+
unlegacy doctor
|
|
103
|
+
unlegacy install-skills --client codex
|
|
104
|
+
unlegacy status
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Development
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
uv run pytest
|
|
111
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "unlegacy-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unlegacy setup CLI for external coding agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
authors = [{ name = "Unlegacy" }]
|
|
12
|
+
dependencies = []
|
|
13
|
+
keywords = ["unlegacy", "mcp", "cli", "coding-agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Topic :: Software Development",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://app.unlegacy.ai"
|
|
25
|
+
Repository = "https://github.com/unlegacy-ai/unlegacy-core"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=8.0"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
unlegacy = "unlegacy_cli.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.package-data]
|
|
37
|
+
unlegacy_cli = ["assets/skills/unlegacy/*"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unlegacy_cli.domain.models import ClientId, McpServerConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _json_object(existing: str) -> dict[str, object]:
|
|
12
|
+
if not existing.strip():
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
parsed = json.loads(existing)
|
|
16
|
+
except json.JSONDecodeError as exc:
|
|
17
|
+
raise ValueError(f"existing JSON config is invalid: {exc}") from exc
|
|
18
|
+
if not isinstance(parsed, dict):
|
|
19
|
+
raise ValueError("existing JSON config must be an object")
|
|
20
|
+
return parsed
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dump_json(data: dict[str, object]) -> str:
|
|
24
|
+
return json.dumps(data, indent=2, sort_keys=True) + "\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _merge_mcp_servers(existing: str, config: McpServerConfig, *, key: str, include_type: bool) -> str:
|
|
28
|
+
data = _json_object(existing)
|
|
29
|
+
servers = data.setdefault(key, {})
|
|
30
|
+
if not isinstance(servers, dict):
|
|
31
|
+
raise ValueError(f"existing {key} must be an object")
|
|
32
|
+
server: dict[str, object] = {
|
|
33
|
+
"url": config.normalized_endpoint(),
|
|
34
|
+
"headers": {"Authorization": config.authorization_header()},
|
|
35
|
+
}
|
|
36
|
+
if include_type:
|
|
37
|
+
server["type"] = "http"
|
|
38
|
+
servers["unlegacy"] = server
|
|
39
|
+
return _dump_json(data)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_claude_mcp_json(existing: str, config: McpServerConfig) -> str:
|
|
43
|
+
return _merge_mcp_servers(existing, config, key="mcpServers", include_type=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_cursor_mcp_json(existing: str, config: McpServerConfig) -> str:
|
|
47
|
+
return _merge_mcp_servers(existing, config, key="mcpServers", include_type=False)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_vscode_mcp_json(existing: str, config: McpServerConfig) -> str:
|
|
51
|
+
return _merge_mcp_servers(existing, config, key="servers", include_type=True)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_opencode_mcp_json(existing: str, config: McpServerConfig) -> str:
|
|
55
|
+
return _merge_mcp_servers(existing, config, key="mcpServers", include_type=True)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _toml_quote(value: str) -> str:
|
|
59
|
+
return json.dumps(value)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _codex_unlegacy_block(config: McpServerConfig) -> str:
|
|
63
|
+
auth = f"Bearer {config.token}"
|
|
64
|
+
return (
|
|
65
|
+
"[mcp_servers.unlegacy]\n"
|
|
66
|
+
f"url = {_toml_quote(config.normalized_endpoint())}\n"
|
|
67
|
+
f"http_headers = {{ Authorization = {_toml_quote(auth)} }}\n"
|
|
68
|
+
"startup_timeout_sec = 20\n"
|
|
69
|
+
"tool_timeout_sec = 60\n"
|
|
70
|
+
"enabled = true\n"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_codex_config_toml(existing: str, config: McpServerConfig) -> str:
|
|
75
|
+
cleaned = re.sub(
|
|
76
|
+
r"(?ms)^# BEGIN UNLEGACY MCP\n.*?^# END UNLEGACY MCP\n?",
|
|
77
|
+
"",
|
|
78
|
+
existing,
|
|
79
|
+
)
|
|
80
|
+
cleaned = re.sub(
|
|
81
|
+
r"(?ms)^\[mcp_servers\.unlegacy\]\n(?:(?!^\[).*\n?)*",
|
|
82
|
+
"",
|
|
83
|
+
cleaned,
|
|
84
|
+
)
|
|
85
|
+
prefix = cleaned.rstrip()
|
|
86
|
+
block = f"# BEGIN UNLEGACY MCP\n{_codex_unlegacy_block(config)}# END UNLEGACY MCP\n"
|
|
87
|
+
return f"{prefix}\n\n{block}" if prefix else block
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_text(path: Path) -> str:
|
|
91
|
+
if not path.exists():
|
|
92
|
+
return ""
|
|
93
|
+
return path.read_text(encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def atomic_write(path: Path, content: str) -> None:
|
|
97
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
temp = path.with_suffix(f"{path.suffix}.tmp")
|
|
99
|
+
temp.write_text(content, encoding="utf-8")
|
|
100
|
+
os.replace(temp, path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class FileSystemClientConfigStore:
|
|
104
|
+
def __init__(self, *, home: Path | None = None, project_dir: Path | None = None) -> None:
|
|
105
|
+
self._home = home or Path.home()
|
|
106
|
+
self._project_dir = project_dir or Path.cwd()
|
|
107
|
+
|
|
108
|
+
def configure(self, client: ClientId, config: McpServerConfig, *, dry_run: bool) -> str:
|
|
109
|
+
path, builder = self._target(client)
|
|
110
|
+
existing = read_text(path)
|
|
111
|
+
content = builder(existing, config)
|
|
112
|
+
if not dry_run:
|
|
113
|
+
if path.exists():
|
|
114
|
+
backup = path.with_suffix(f"{path.suffix}.bak")
|
|
115
|
+
backup.write_text(existing, encoding="utf-8")
|
|
116
|
+
atomic_write(path, content)
|
|
117
|
+
action = "would write" if dry_run else "wrote"
|
|
118
|
+
return f"{client.label}: {action} {path}. {reload_instruction(client)}"
|
|
119
|
+
|
|
120
|
+
def has_config(self, client: ClientId, config: McpServerConfig) -> bool:
|
|
121
|
+
path, _ = self._target(client)
|
|
122
|
+
existing = read_text(path)
|
|
123
|
+
if not existing:
|
|
124
|
+
return False
|
|
125
|
+
marker = config.normalized_endpoint()
|
|
126
|
+
return "unlegacy" in existing and marker in existing
|
|
127
|
+
|
|
128
|
+
def _target(self, client: ClientId):
|
|
129
|
+
if client == ClientId.CLAUDE_CODE:
|
|
130
|
+
return self._project_dir / ".mcp.json", build_claude_mcp_json
|
|
131
|
+
if client == ClientId.CODEX:
|
|
132
|
+
return self._home / ".codex" / "config.toml", build_codex_config_toml
|
|
133
|
+
if client == ClientId.CURSOR:
|
|
134
|
+
return self._project_dir / ".cursor" / "mcp.json", build_cursor_mcp_json
|
|
135
|
+
if client == ClientId.VSCODE_COPILOT:
|
|
136
|
+
return self._project_dir / ".vscode" / "mcp.json", build_vscode_mcp_json
|
|
137
|
+
if client == ClientId.OPENCODE:
|
|
138
|
+
return self._project_dir / ".opencode" / "mcp.json", build_opencode_mcp_json
|
|
139
|
+
raise ValueError(f"unsupported client: {client}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def reload_instruction(client: ClientId) -> str:
|
|
143
|
+
if client == ClientId.CLAUDE_CODE:
|
|
144
|
+
return "Restart Claude Code to load the MCP server."
|
|
145
|
+
if client == ClientId.CODEX:
|
|
146
|
+
return "Restart Codex or open a new Codex session, then use /mcp to inspect the server."
|
|
147
|
+
if client == ClientId.CURSOR:
|
|
148
|
+
return "Reload the Cursor window to activate the MCP server."
|
|
149
|
+
if client == ClientId.VSCODE_COPILOT:
|
|
150
|
+
return "Reload VS Code or restart the Extension Host to activate the MCP server."
|
|
151
|
+
if client == ClientId.OPENCODE:
|
|
152
|
+
return "Restart OpenCode to activate the MCP server."
|
|
153
|
+
return "Restart the client to activate the MCP server."
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from unlegacy_cli.domain.models import McpServerConfig
|
|
10
|
+
|
|
11
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class HttpMcpGateway:
|
|
15
|
+
def __init__(self, *, timeout_seconds: float = 20.0) -> None:
|
|
16
|
+
self._timeout_seconds = timeout_seconds
|
|
17
|
+
|
|
18
|
+
def validate(self, config: McpServerConfig) -> None:
|
|
19
|
+
self.list_tools(config)
|
|
20
|
+
|
|
21
|
+
def list_tools(self, config: McpServerConfig) -> list[str]:
|
|
22
|
+
session = _JsonRpcSession(config, timeout_seconds=self._timeout_seconds)
|
|
23
|
+
init = session.request(
|
|
24
|
+
"initialize",
|
|
25
|
+
{
|
|
26
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
27
|
+
"capabilities": {"tools": {}},
|
|
28
|
+
"clientInfo": {"name": "unlegacy-cli", "version": "0.1.0"},
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
server_version = init.get("protocolVersion")
|
|
32
|
+
if server_version != PROTOCOL_VERSION:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"MCP protocol mismatch: server speaks {server_version!r}, CLI speaks {PROTOCOL_VERSION!r}"
|
|
35
|
+
)
|
|
36
|
+
session.notify("notifications/initialized", {})
|
|
37
|
+
result = session.request("tools/list", {})
|
|
38
|
+
tools = result.get("tools", [])
|
|
39
|
+
if not isinstance(tools, list):
|
|
40
|
+
raise ValueError("MCP tools/list returned an invalid response")
|
|
41
|
+
return [str(tool.get("name")) for tool in tools if isinstance(tool, dict)]
|
|
42
|
+
|
|
43
|
+
def list_repos(self, config: McpServerConfig) -> list[dict[str, object]]:
|
|
44
|
+
session = _JsonRpcSession(config, timeout_seconds=self._timeout_seconds)
|
|
45
|
+
session.request(
|
|
46
|
+
"initialize",
|
|
47
|
+
{
|
|
48
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
49
|
+
"capabilities": {"tools": {}},
|
|
50
|
+
"clientInfo": {"name": "unlegacy-cli", "version": "0.1.0"},
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
session.notify("notifications/initialized", {})
|
|
54
|
+
result = session.request("tools/call", {"name": "list_repos", "arguments": {}})
|
|
55
|
+
text = _tool_text(result)
|
|
56
|
+
try:
|
|
57
|
+
rows = json.loads(text)
|
|
58
|
+
except json.JSONDecodeError:
|
|
59
|
+
return []
|
|
60
|
+
return rows if isinstance(rows, list) else []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class _JsonRpcSession:
|
|
64
|
+
def __init__(self, config: McpServerConfig, *, timeout_seconds: float) -> None:
|
|
65
|
+
self._config = config
|
|
66
|
+
self._timeout_seconds = timeout_seconds
|
|
67
|
+
self._ids = itertools.count(1)
|
|
68
|
+
self._session_id: str | None = None
|
|
69
|
+
|
|
70
|
+
def request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
rid = next(self._ids)
|
|
72
|
+
body = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
|
|
73
|
+
response = self._post(body)
|
|
74
|
+
if "error" in response:
|
|
75
|
+
error = response["error"]
|
|
76
|
+
if isinstance(error, dict):
|
|
77
|
+
raise ValueError(f"{method}: {error.get('message', 'MCP error')}")
|
|
78
|
+
raise ValueError(f"{method}: MCP error")
|
|
79
|
+
result = response.get("result", {})
|
|
80
|
+
return result if isinstance(result, dict) else {}
|
|
81
|
+
|
|
82
|
+
def notify(self, method: str, params: dict[str, Any]) -> None:
|
|
83
|
+
self._post({"jsonrpc": "2.0", "method": method, "params": params}, expect_response=False)
|
|
84
|
+
|
|
85
|
+
def _post(self, body: dict[str, Any], *, expect_response: bool = True) -> dict[str, Any]:
|
|
86
|
+
headers = {
|
|
87
|
+
"Accept": "application/json, text/event-stream",
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
"Authorization": self._config.authorization_header(),
|
|
90
|
+
}
|
|
91
|
+
if self._session_id:
|
|
92
|
+
headers["Mcp-Session-Id"] = self._session_id
|
|
93
|
+
request = urllib.request.Request(
|
|
94
|
+
self._config.normalized_endpoint(),
|
|
95
|
+
data=json.dumps(body).encode("utf-8"),
|
|
96
|
+
headers=headers,
|
|
97
|
+
method="POST",
|
|
98
|
+
)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(request, timeout=self._timeout_seconds) as response:
|
|
101
|
+
self._session_id = response.headers.get("Mcp-Session-Id") or self._session_id
|
|
102
|
+
payload = response.read().decode("utf-8")
|
|
103
|
+
content_type = response.headers.get("content-type", "")
|
|
104
|
+
except urllib.error.HTTPError as exc:
|
|
105
|
+
if exc.code == 401:
|
|
106
|
+
raise ValueError("invalid or expired MCP token") from exc
|
|
107
|
+
raise ValueError(f"MCP request failed with HTTP {exc.code}") from exc
|
|
108
|
+
except urllib.error.URLError as exc:
|
|
109
|
+
raise ValueError(f"MCP server is unreachable: {exc.reason}") from exc
|
|
110
|
+
if not expect_response or not payload.strip():
|
|
111
|
+
return {}
|
|
112
|
+
return _decode_payload(payload, content_type)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _decode_payload(payload: str, content_type: str) -> dict[str, Any]:
|
|
116
|
+
if "text/event-stream" in content_type:
|
|
117
|
+
for line in payload.splitlines():
|
|
118
|
+
if line.startswith("data:"):
|
|
119
|
+
data = line.removeprefix("data:").strip()
|
|
120
|
+
if data:
|
|
121
|
+
parsed = json.loads(data)
|
|
122
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
123
|
+
return {}
|
|
124
|
+
parsed = json.loads(payload)
|
|
125
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _tool_text(result: dict[str, Any]) -> str:
|
|
129
|
+
parts = result.get("content", [])
|
|
130
|
+
if not isinstance(parts, list):
|
|
131
|
+
return ""
|
|
132
|
+
return "\n".join(
|
|
133
|
+
str(part.get("text", ""))
|
|
134
|
+
for part in parts
|
|
135
|
+
if isinstance(part, dict) and part.get("type") == "text"
|
|
136
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
from importlib import resources
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unlegacy_cli.domain.models import ClientId, SkillPackage
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PackagedSkillRepository:
|
|
12
|
+
def load(self) -> SkillPackage:
|
|
13
|
+
resource = resources.files("unlegacy_cli").joinpath("assets/skills/unlegacy/SKILL.md")
|
|
14
|
+
content = resource.read_text(encoding="utf-8")
|
|
15
|
+
name = _frontmatter_value(content, "name")
|
|
16
|
+
version = _frontmatter_value(content, "version")
|
|
17
|
+
for required in ["list_repos", "repo_info", "read_doc", "Do not guess"]:
|
|
18
|
+
if required.lower() not in content.lower():
|
|
19
|
+
raise ValueError(f"packaged skill missing required guidance: {required}")
|
|
20
|
+
return SkillPackage(name=name, version=version, content=content)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _frontmatter_value(content: str, key: str) -> str:
|
|
24
|
+
match = re.search(rf"^{re.escape(key)}:\s*(.+)$", content, re.MULTILINE)
|
|
25
|
+
if not match:
|
|
26
|
+
raise ValueError(f"packaged skill missing {key} frontmatter")
|
|
27
|
+
return match.group(1).strip().strip("\"'")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class FileSystemSkillInstaller:
|
|
31
|
+
def __init__(self, *, repository: PackagedSkillRepository | None = None, home: Path | None = None) -> None:
|
|
32
|
+
self._repository = repository or PackagedSkillRepository()
|
|
33
|
+
self._home = home or Path.home()
|
|
34
|
+
|
|
35
|
+
def install(self, client: ClientId, *, dry_run: bool) -> str:
|
|
36
|
+
skill = self._repository.load()
|
|
37
|
+
target = self._target(client, skill.name)
|
|
38
|
+
if not dry_run:
|
|
39
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
(target / "SKILL.md").write_text(skill.content, encoding="utf-8")
|
|
41
|
+
return f"{client.label}: {'would install' if dry_run else 'installed'} skill {skill.name}@{skill.version} to {target}"
|
|
42
|
+
|
|
43
|
+
def is_installed(self, client: ClientId) -> bool:
|
|
44
|
+
skill = self._repository.load()
|
|
45
|
+
path = self._target(client, skill.name) / "SKILL.md"
|
|
46
|
+
if not path.exists():
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
content = path.read_text(encoding="utf-8")
|
|
50
|
+
except OSError:
|
|
51
|
+
return False
|
|
52
|
+
return f"version: {skill.version}" in content and "name: unlegacy" in content
|
|
53
|
+
|
|
54
|
+
def version(self) -> str:
|
|
55
|
+
return self._repository.load().version
|
|
56
|
+
|
|
57
|
+
def _target(self, client: ClientId, name: str) -> Path:
|
|
58
|
+
if client == ClientId.CLAUDE_CODE:
|
|
59
|
+
return self._home / ".claude" / "skills" / name
|
|
60
|
+
if client == ClientId.CODEX:
|
|
61
|
+
return self._home / ".codex" / "skills" / name
|
|
62
|
+
if client == ClientId.CURSOR:
|
|
63
|
+
return self._home / ".cursor" / "skills" / name
|
|
64
|
+
if client == ClientId.VSCODE_COPILOT:
|
|
65
|
+
return self._home / ".vscode" / "extensions" / "unlegacy" / "skills" / name
|
|
66
|
+
if client == ClientId.OPENCODE:
|
|
67
|
+
return self._home / ".config" / "opencode" / "skills" / name
|
|
68
|
+
raise ValueError(f"unsupported client: {client}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def copy_packaged_skill_to(path: Path) -> None:
|
|
72
|
+
resource_dir = resources.files("unlegacy_cli").joinpath("assets/skills/unlegacy")
|
|
73
|
+
if path.exists():
|
|
74
|
+
shutil.rmtree(path)
|
|
75
|
+
path.mkdir(parents=True)
|
|
76
|
+
for item in resource_dir.iterdir():
|
|
77
|
+
if item.is_file():
|
|
78
|
+
(path / item.name).write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
|