unlegacy-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unlegacy_cli/__init__.py +3 -0
- unlegacy_cli/__main__.py +5 -0
- unlegacy_cli/adapters/client_configs.py +153 -0
- unlegacy_cli/adapters/mcp_http.py +136 -0
- unlegacy_cli/adapters/skills.py +78 -0
- unlegacy_cli/application/setup.py +158 -0
- unlegacy_cli/assets/skills/unlegacy/SKILL.md +44 -0
- unlegacy_cli/cli.py +404 -0
- unlegacy_cli/domain/models.py +85 -0
- unlegacy_cli/domain/ports.py +40 -0
- unlegacy_cli-0.1.0.dist-info/METADATA +130 -0
- unlegacy_cli-0.1.0.dist-info/RECORD +15 -0
- unlegacy_cli-0.1.0.dist-info/WHEEL +5 -0
- unlegacy_cli-0.1.0.dist-info/entry_points.txt +2 -0
- unlegacy_cli-0.1.0.dist-info/top_level.txt +1 -0
unlegacy_cli/__init__.py
ADDED
unlegacy_cli/__main__.py
ADDED
|
@@ -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")
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from unlegacy_cli.domain.models import (
|
|
6
|
+
ClientId,
|
|
7
|
+
DoctorReport,
|
|
8
|
+
McpServerConfig,
|
|
9
|
+
SetupResult,
|
|
10
|
+
mask_token,
|
|
11
|
+
)
|
|
12
|
+
from unlegacy_cli.domain.ports import ClientConfigStore, McpGateway, SkillInstaller
|
|
13
|
+
|
|
14
|
+
REQUIRED_TOOLS = {"list_repos"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SetupCommand:
|
|
19
|
+
token: str
|
|
20
|
+
endpoint: str
|
|
21
|
+
clients: list[ClientId]
|
|
22
|
+
dry_run: bool = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class DoctorCommand:
|
|
27
|
+
token: str | None
|
|
28
|
+
endpoint: str
|
|
29
|
+
clients: list[ClientId]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DoctorService:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
mcp_gateway: McpGateway,
|
|
37
|
+
client_configs: ClientConfigStore,
|
|
38
|
+
skills: SkillInstaller,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._mcp_gateway = mcp_gateway
|
|
41
|
+
self._client_configs = client_configs
|
|
42
|
+
self._skills = skills
|
|
43
|
+
|
|
44
|
+
def run(self, command: DoctorCommand) -> DoctorReport:
|
|
45
|
+
token = (command.token or "").strip()
|
|
46
|
+
if not token:
|
|
47
|
+
return DoctorReport(
|
|
48
|
+
ok=False,
|
|
49
|
+
token_present=False,
|
|
50
|
+
mcp_initialized=False,
|
|
51
|
+
required_tools_present=False,
|
|
52
|
+
repo_count=0,
|
|
53
|
+
message="missing MCP token; pass --token or set UNLEGACY_MCP_TOKEN",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
config = McpServerConfig(endpoint=command.endpoint, token=token)
|
|
57
|
+
try:
|
|
58
|
+
tools = set(self._mcp_gateway.list_tools(config))
|
|
59
|
+
repos = self._mcp_gateway.list_repos(config)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
return DoctorReport(
|
|
62
|
+
ok=False,
|
|
63
|
+
token_present=True,
|
|
64
|
+
mcp_initialized=False,
|
|
65
|
+
required_tools_present=False,
|
|
66
|
+
repo_count=0,
|
|
67
|
+
message=str(exc),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
missing_tools = REQUIRED_TOOLS - tools
|
|
71
|
+
configured = [
|
|
72
|
+
client for client in command.clients if self._client_configs.has_config(client, config)
|
|
73
|
+
]
|
|
74
|
+
missing_config = [client for client in command.clients if client not in configured]
|
|
75
|
+
missing_skill = [
|
|
76
|
+
client for client in command.clients if not self._skills.is_installed(client)
|
|
77
|
+
]
|
|
78
|
+
ok = not missing_tools and not missing_config and not missing_skill
|
|
79
|
+
message = "Unlegacy MCP is ready" if ok else "Unlegacy setup needs attention"
|
|
80
|
+
return DoctorReport(
|
|
81
|
+
ok=ok,
|
|
82
|
+
token_present=True,
|
|
83
|
+
mcp_initialized=True,
|
|
84
|
+
required_tools_present=not missing_tools,
|
|
85
|
+
repo_count=len(repos),
|
|
86
|
+
configured_clients=configured,
|
|
87
|
+
missing_config_clients=missing_config,
|
|
88
|
+
missing_skill_clients=missing_skill,
|
|
89
|
+
message=message,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class SetupService:
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
mcp_gateway: McpGateway,
|
|
98
|
+
client_configs: ClientConfigStore,
|
|
99
|
+
skills: SkillInstaller,
|
|
100
|
+
) -> None:
|
|
101
|
+
self._mcp_gateway = mcp_gateway
|
|
102
|
+
self._client_configs = client_configs
|
|
103
|
+
self._skills = skills
|
|
104
|
+
self._doctor = DoctorService(
|
|
105
|
+
mcp_gateway=mcp_gateway,
|
|
106
|
+
client_configs=client_configs,
|
|
107
|
+
skills=skills,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def setup(self, command: SetupCommand) -> SetupResult:
|
|
111
|
+
config = McpServerConfig(endpoint=command.endpoint, token=command.token.strip())
|
|
112
|
+
masked = mask_token(config.token)
|
|
113
|
+
try:
|
|
114
|
+
self._mcp_gateway.validate(config)
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
return SetupResult(
|
|
117
|
+
ok=False,
|
|
118
|
+
config=config,
|
|
119
|
+
masked_token=masked,
|
|
120
|
+
skill_version=self._skills.version(),
|
|
121
|
+
configured_clients=[],
|
|
122
|
+
config_messages=[],
|
|
123
|
+
skill_messages=[],
|
|
124
|
+
doctor=DoctorReport(
|
|
125
|
+
ok=False,
|
|
126
|
+
token_present=bool(config.token),
|
|
127
|
+
mcp_initialized=False,
|
|
128
|
+
required_tools_present=False,
|
|
129
|
+
repo_count=0,
|
|
130
|
+
message=str(exc),
|
|
131
|
+
),
|
|
132
|
+
message=f"Setup stopped: {exc}",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
config_messages: list[str] = []
|
|
136
|
+
skill_messages: list[str] = []
|
|
137
|
+
configured_clients: list[ClientId] = []
|
|
138
|
+
for client in command.clients:
|
|
139
|
+
config_messages.append(
|
|
140
|
+
self._client_configs.configure(client, config, dry_run=command.dry_run)
|
|
141
|
+
)
|
|
142
|
+
skill_messages.append(self._skills.install(client, dry_run=command.dry_run))
|
|
143
|
+
configured_clients.append(client)
|
|
144
|
+
|
|
145
|
+
doctor = self._doctor.run(
|
|
146
|
+
DoctorCommand(token=config.token, endpoint=config.endpoint, clients=command.clients)
|
|
147
|
+
)
|
|
148
|
+
return SetupResult(
|
|
149
|
+
ok=doctor.ok,
|
|
150
|
+
config=config,
|
|
151
|
+
masked_token=masked,
|
|
152
|
+
skill_version=self._skills.version(),
|
|
153
|
+
configured_clients=configured_clients,
|
|
154
|
+
config_messages=config_messages,
|
|
155
|
+
skill_messages=skill_messages,
|
|
156
|
+
doctor=doctor,
|
|
157
|
+
message="Setup complete" if doctor.ok else doctor.message,
|
|
158
|
+
)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: unlegacy
|
|
3
|
+
version: 0.1.0
|
|
4
|
+
description: Use Unlegacy MCP before changing or explaining repositories that have platform-generated docs or a knowledge graph.
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Unlegacy
|
|
8
|
+
|
|
9
|
+
Use this skill when the user asks about a repository, feature, bug, business process, migration, impact analysis, or implementation spec that may be represented in Unlegacy.
|
|
10
|
+
|
|
11
|
+
Unlegacy is the hosted repo memory layer. The Unlegacy platform already generated documentation and knowledge graph data. The local CLI only connects your coding agent to that MCP surface.
|
|
12
|
+
|
|
13
|
+
## Default Workflow
|
|
14
|
+
|
|
15
|
+
1. Call `list_repos` first unless the user gave an exact repo id or full name.
|
|
16
|
+
2. Choose the most likely repository by name and description. If there is ambiguity, say which repos are plausible and ask a short question.
|
|
17
|
+
3. Call `repo_info` for the selected repo to understand indexed status and recommended context.
|
|
18
|
+
4. Prefer generated docs before source when the question is about behavior, architecture, onboarding, business rules, or implementation intent.
|
|
19
|
+
5. Use `read_doc` when you know the doc path. If docs discovery tools are unavailable, use `repo_info` and code/search evidence to identify likely docs.
|
|
20
|
+
6. Use `search`, `symbol_context`, `impact`, `cypher`, and `get_code` when you need graph or source verification.
|
|
21
|
+
7. Do not guess. If Unlegacy lacks docs, KG data, or source evidence for a claim, say what is missing and verify through the repository.
|
|
22
|
+
|
|
23
|
+
## Answer Pattern
|
|
24
|
+
|
|
25
|
+
For explanations:
|
|
26
|
+
- Start with the direct answer.
|
|
27
|
+
- Cite the repo, docs path, symbols, or files that support it.
|
|
28
|
+
- Separate confirmed facts from inference.
|
|
29
|
+
|
|
30
|
+
For implementation specs:
|
|
31
|
+
- State the target repo and why it was selected.
|
|
32
|
+
- Summarize the relevant docs and KG/source evidence.
|
|
33
|
+
- List implementation slices, affected files/modules, risks, and verification commands.
|
|
34
|
+
- Include open questions only when the evidence is genuinely insufficient.
|
|
35
|
+
|
|
36
|
+
## Tool Guidance
|
|
37
|
+
|
|
38
|
+
- `list_repos`: discover available repositories and pick the right one.
|
|
39
|
+
- `repo_info`: get a compact summary of the selected repository and indexed version.
|
|
40
|
+
- `read_doc`: read generated documentation by path.
|
|
41
|
+
- `search`: find code graph nodes related to a query.
|
|
42
|
+
- `symbol_context`: inspect a symbol neighborhood.
|
|
43
|
+
- `impact`: inspect likely blast radius before changing code.
|
|
44
|
+
- `get_code`: read source only after selecting a repo and path.
|
unlegacy_cli/cli.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from unlegacy_cli.adapters.client_configs import FileSystemClientConfigStore
|
|
9
|
+
from unlegacy_cli.adapters.mcp_http import HttpMcpGateway
|
|
10
|
+
from unlegacy_cli.adapters.skills import FileSystemSkillInstaller
|
|
11
|
+
from unlegacy_cli.application.setup import DoctorCommand, DoctorService, SetupCommand, SetupService
|
|
12
|
+
from unlegacy_cli.domain.models import ClientId, SUPPORTED_CLIENTS
|
|
13
|
+
|
|
14
|
+
DEFAULT_ENDPOINT = "https://app.unlegacy.ai/ai/mcp"
|
|
15
|
+
LOCAL_ENDPOINT = "http://localhost:5173/ai/mcp"
|
|
16
|
+
|
|
17
|
+
RESET = "\033[0m"
|
|
18
|
+
DIM = "\033[2m"
|
|
19
|
+
CYAN = "\033[96m"
|
|
20
|
+
MINT = "\033[38;2;145;245;247m"
|
|
21
|
+
TEAL = "\033[36m"
|
|
22
|
+
LIME = "\033[92m"
|
|
23
|
+
YELLOW = "\033[93m"
|
|
24
|
+
CORAL = "\033[91m"
|
|
25
|
+
BOLD = "\033[1m"
|
|
26
|
+
|
|
27
|
+
LOGO_PIXEL_MAP = [
|
|
28
|
+
"...........lllllllllllyyy......",
|
|
29
|
+
"..........llllllllllllllyy.yyyy",
|
|
30
|
+
".........llllllllllllllllly.yyy",
|
|
31
|
+
"........llllllllllllllllllly.yy",
|
|
32
|
+
"........lllccccccccclllllllly..",
|
|
33
|
+
"........lcccccccccccccllllllly.",
|
|
34
|
+
"........cccccccccccccccllllllyy",
|
|
35
|
+
"........ccccccccc.....cclllllly",
|
|
36
|
+
"..lll...cccccccc.cccc..ccllllly",
|
|
37
|
+
".llllc..cccccmm.mmcccc..cclllll",
|
|
38
|
+
"lllllcc.ccccmmm.mmmcccc.cclllll",
|
|
39
|
+
"llllccc.cccmmmm.mmmmccc.cccllll",
|
|
40
|
+
"llllccc.ccmmmmm.mmmmmcc.cccllll",
|
|
41
|
+
"llllccc.cmmmmmm.mmmmmmc.cccllll",
|
|
42
|
+
"llllccc.cmmmmmm.mmmmmmc.cccllll",
|
|
43
|
+
"llllccc.cmmmmmm.mmmmmmc.cccllll",
|
|
44
|
+
"llllccc.cmmmmmm.mmmmmmc.cccllll",
|
|
45
|
+
"llllccc.cmmmmmm.mmmmmmc.cccllll",
|
|
46
|
+
"llllccc.ccmmmmm.mmmmmcc.cccllll",
|
|
47
|
+
"llllccc.cccmmmm.mmmmccc.cccllll",
|
|
48
|
+
"lllllcc..cccmmm.mmmcccc.cclllll",
|
|
49
|
+
"lllllcc..ccccm..mmccccc..cllll.",
|
|
50
|
+
"ylllllcc.......cccccccc...lll..",
|
|
51
|
+
"yllllllcc.....ccccccccc........",
|
|
52
|
+
".yllllllccccccccccccccc........",
|
|
53
|
+
".ylllllllcccccccccccccl........",
|
|
54
|
+
"..yllllllllccccccccclll........",
|
|
55
|
+
"....llllllllllllllllll.........",
|
|
56
|
+
".....lllllllllllllllll.........",
|
|
57
|
+
"......yllllllllllllll..........",
|
|
58
|
+
".......yyllllllllll............",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
LOGO_PIXEL_COLORS = {
|
|
62
|
+
"c": CYAN,
|
|
63
|
+
"m": MINT,
|
|
64
|
+
"l": LIME,
|
|
65
|
+
"y": YELLOW,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
parser = build_parser()
|
|
71
|
+
args = parser.parse_args(argv)
|
|
72
|
+
if not hasattr(args, "func"):
|
|
73
|
+
parser.print_help()
|
|
74
|
+
return 0
|
|
75
|
+
try:
|
|
76
|
+
return int(args.func(args))
|
|
77
|
+
except KeyboardInterrupt:
|
|
78
|
+
print("\nCanceled.")
|
|
79
|
+
return 130
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
83
|
+
parser = argparse.ArgumentParser(prog="unlegacy", description="Connect coding agents to Unlegacy MCP.")
|
|
84
|
+
sub = parser.add_subparsers(dest="command")
|
|
85
|
+
|
|
86
|
+
setup = sub.add_parser(
|
|
87
|
+
"setup",
|
|
88
|
+
usage="unlegacy setup [--token TOKEN] [--client CLIENT] [--endpoint URL]",
|
|
89
|
+
help="Configure MCP and install the Unlegacy skill.",
|
|
90
|
+
)
|
|
91
|
+
add_common_options(setup)
|
|
92
|
+
setup.add_argument("--dry-run", action="store_true", help="Print planned writes without changing files.")
|
|
93
|
+
setup.set_defaults(func=run_setup)
|
|
94
|
+
|
|
95
|
+
doctor = sub.add_parser("doctor", help="Validate token, MCP tools, config, and skills.")
|
|
96
|
+
add_common_options(doctor, token_required=False)
|
|
97
|
+
doctor.set_defaults(func=run_doctor)
|
|
98
|
+
|
|
99
|
+
install_skills = sub.add_parser("install-skills", help="Install only the packaged Unlegacy skill.")
|
|
100
|
+
install_skills.add_argument(
|
|
101
|
+
"--client",
|
|
102
|
+
action="append",
|
|
103
|
+
choices=[c.value for c in SUPPORTED_CLIENTS] + ["all"],
|
|
104
|
+
help="Client to install into. Repeat or use all.",
|
|
105
|
+
)
|
|
106
|
+
install_skills.add_argument("--dry-run", action="store_true", help="Print planned writes without changing files.")
|
|
107
|
+
install_skills.set_defaults(func=run_install_skills)
|
|
108
|
+
|
|
109
|
+
status = sub.add_parser("status", help="Show local Unlegacy CLI setup targets.")
|
|
110
|
+
status.set_defaults(func=run_status)
|
|
111
|
+
return parser
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def add_common_options(parser: argparse.ArgumentParser, *, token_required: bool = True) -> None:
|
|
115
|
+
parser.add_argument("--token", help="Unlegacy MCP token. Defaults to UNLEGACY_MCP_TOKEN.")
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--endpoint",
|
|
118
|
+
help=(
|
|
119
|
+
"Unlegacy MCP endpoint. Defaults to UNLEGACY_MCP_ENDPOINT, "
|
|
120
|
+
f"or prompts during setup. Production default: {DEFAULT_ENDPOINT}"
|
|
121
|
+
),
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument(
|
|
124
|
+
"--client",
|
|
125
|
+
action="append",
|
|
126
|
+
choices=[c.value for c in SUPPORTED_CLIENTS] + ["all"],
|
|
127
|
+
help="Client to configure. Repeat for multiple clients or use all.",
|
|
128
|
+
)
|
|
129
|
+
if token_required:
|
|
130
|
+
parser.epilog = "The setup flow will prompt for the token when --token and UNLEGACY_MCP_TOKEN are absent."
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def run_setup(args: argparse.Namespace) -> int:
|
|
134
|
+
color = color_enabled()
|
|
135
|
+
print(render_title("setup", color=color))
|
|
136
|
+
print(render_logo(color=color))
|
|
137
|
+
print(
|
|
138
|
+
paint("Unlegacy setup", LIME, color)
|
|
139
|
+
+ " connects local coding agents to docs and KG already generated in Unlegacy."
|
|
140
|
+
)
|
|
141
|
+
print()
|
|
142
|
+
token_will_prompt = not (args.token or os.environ.get("UNLEGACY_MCP_TOKEN") or "").strip()
|
|
143
|
+
endpoint_will_prompt = not (args.endpoint or os.environ.get("UNLEGACY_MCP_ENDPOINT") or "").strip()
|
|
144
|
+
clients_will_prompt = not args.client and sys.stdin.isatty()
|
|
145
|
+
token = token_from_args(args, prompt=True)
|
|
146
|
+
if token_will_prompt:
|
|
147
|
+
print()
|
|
148
|
+
endpoint = endpoint_from_args(args, prompt=True)
|
|
149
|
+
if endpoint_will_prompt:
|
|
150
|
+
print()
|
|
151
|
+
clients = clients_from_args(args.client, interactive=True)
|
|
152
|
+
if clients_will_prompt:
|
|
153
|
+
print()
|
|
154
|
+
print()
|
|
155
|
+
service = _setup_service()
|
|
156
|
+
result = service.setup(
|
|
157
|
+
SetupCommand(
|
|
158
|
+
token=token,
|
|
159
|
+
endpoint=endpoint,
|
|
160
|
+
clients=clients,
|
|
161
|
+
dry_run=bool(args.dry_run),
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
print(f"Token: {result.masked_token}")
|
|
165
|
+
messages = result.config_messages + result.skill_messages
|
|
166
|
+
if messages:
|
|
167
|
+
print()
|
|
168
|
+
for message in messages:
|
|
169
|
+
print(f"- {message}")
|
|
170
|
+
print()
|
|
171
|
+
print_doctor(result.doctor)
|
|
172
|
+
if result.ok:
|
|
173
|
+
print()
|
|
174
|
+
print("Open your agent and ask: 'Using Unlegacy, which repo owns this process and what docs should I read first?'")
|
|
175
|
+
return 0 if result.ok else 1
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_doctor(args: argparse.Namespace) -> int:
|
|
179
|
+
token = token_from_args(args, prompt=False)
|
|
180
|
+
endpoint = endpoint_from_args(args, prompt=False)
|
|
181
|
+
clients = clients_from_args(args.client, interactive=False)
|
|
182
|
+
service = _doctor_service()
|
|
183
|
+
report = service.run(DoctorCommand(token=token, endpoint=endpoint, clients=clients))
|
|
184
|
+
print_doctor(report)
|
|
185
|
+
return 0 if report.ok else 1
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_install_skills(args: argparse.Namespace) -> int:
|
|
189
|
+
clients = clients_from_args(args.client, interactive=False)
|
|
190
|
+
installer = FileSystemSkillInstaller()
|
|
191
|
+
for client in clients:
|
|
192
|
+
print(installer.install(client, dry_run=bool(args.dry_run)))
|
|
193
|
+
return 0
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_status(args: argparse.Namespace) -> int:
|
|
197
|
+
del args
|
|
198
|
+
print("Unlegacy setup targets:")
|
|
199
|
+
print(f"- Project config directory: {Path.cwd()}")
|
|
200
|
+
print(f"- User home: {Path.home()}")
|
|
201
|
+
print("- Supported clients: " + ", ".join(client.value for client in SUPPORTED_CLIENTS))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def token_from_args(args: argparse.Namespace, *, prompt: bool) -> str:
|
|
206
|
+
token = (args.token or os.environ.get("UNLEGACY_MCP_TOKEN") or "").strip()
|
|
207
|
+
if token or not prompt:
|
|
208
|
+
return token
|
|
209
|
+
print("Paste your Unlegacy MCP token from Settings -> MCP tokens.")
|
|
210
|
+
return input("Unlegacy MCP token: ").strip()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def endpoint_from_args(args: argparse.Namespace, *, prompt: bool) -> str:
|
|
214
|
+
endpoint = (args.endpoint or os.environ.get("UNLEGACY_MCP_ENDPOINT") or "").strip()
|
|
215
|
+
if endpoint:
|
|
216
|
+
return endpoint
|
|
217
|
+
if not prompt:
|
|
218
|
+
return DEFAULT_ENDPOINT
|
|
219
|
+
return input(f"Unlegacy MCP endpoint [{LOCAL_ENDPOINT}]: ").strip() or LOCAL_ENDPOINT
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def clients_from_args(values: list[str] | None, *, interactive: bool) -> list[ClientId]:
|
|
223
|
+
if values:
|
|
224
|
+
if "all" in values:
|
|
225
|
+
return list(SUPPORTED_CLIENTS)
|
|
226
|
+
return [ClientId(value) for value in values]
|
|
227
|
+
if not interactive or not sys.stdin.isatty():
|
|
228
|
+
return [ClientId.CLAUDE_CODE, ClientId.CODEX]
|
|
229
|
+
return interactive_client_selector(
|
|
230
|
+
clients=SUPPORTED_CLIENTS,
|
|
231
|
+
default_selected={ClientId.CLAUDE_CODE, ClientId.CODEX},
|
|
232
|
+
read_key=read_terminal_key,
|
|
233
|
+
write=sys.stdout.write,
|
|
234
|
+
color=color_enabled(),
|
|
235
|
+
redraw=True,
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def print_doctor(report) -> None:
|
|
240
|
+
status = "PASS" if report.ok else "FAIL"
|
|
241
|
+
print(f"Doctor: {status} - {report.message}")
|
|
242
|
+
print(f"- Token present: {'yes' if report.token_present else 'no'}")
|
|
243
|
+
print(f"- MCP initialized: {'yes' if report.mcp_initialized else 'no'}")
|
|
244
|
+
print(f"- Required tools present: {'yes' if report.required_tools_present else 'no'}")
|
|
245
|
+
print(f"- Repositories visible: {report.repo_count}")
|
|
246
|
+
if report.configured_clients:
|
|
247
|
+
print("- Configured clients: " + ", ".join(client.label for client in report.configured_clients))
|
|
248
|
+
if report.missing_config_clients:
|
|
249
|
+
print("- Missing config: " + ", ".join(client.label for client in report.missing_config_clients))
|
|
250
|
+
if report.missing_skill_clients:
|
|
251
|
+
print("- Missing skills: " + ", ".join(client.label for client in report.missing_skill_clients))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def color_enabled() -> bool:
|
|
255
|
+
if os.environ.get("NO_COLOR"):
|
|
256
|
+
return False
|
|
257
|
+
if os.environ.get("UNLEGACY_FORCE_COLOR") or os.environ.get("FORCE_COLOR"):
|
|
258
|
+
return True
|
|
259
|
+
return sys.stdout.isatty()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def paint(text: str, color_code: str, enabled: bool) -> str:
|
|
263
|
+
if not enabled:
|
|
264
|
+
return text
|
|
265
|
+
return f"{color_code}{text}{RESET}"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def render_title(command: str, *, color: bool) -> str:
|
|
269
|
+
return f"{paint('➜', CORAL, color)} {paint('unlegacy', LIME, color)} {paint(command, BOLD, color)}"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def render_logo(*, color: bool) -> str:
|
|
273
|
+
mark = render_logo_mark(color=color)
|
|
274
|
+
wordmark = (
|
|
275
|
+
f"{paint('UN', CYAN + BOLD, color)}"
|
|
276
|
+
f"{paint('LEG', LIME + BOLD, color)}"
|
|
277
|
+
f"{paint('ACY', YELLOW + BOLD, color)}"
|
|
278
|
+
f" {paint('0.1.0', TEAL, color)}"
|
|
279
|
+
)
|
|
280
|
+
return f"\n{mark}\n{wordmark}\n"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def render_logo_mark(*, color: bool) -> str:
|
|
284
|
+
lines: list[str] = []
|
|
285
|
+
for row in LOGO_PIXEL_MAP:
|
|
286
|
+
rendered = []
|
|
287
|
+
for cell in row:
|
|
288
|
+
if cell == ".":
|
|
289
|
+
rendered.append(" ")
|
|
290
|
+
else:
|
|
291
|
+
rendered.append(paint("◢", LOGO_PIXEL_COLORS[cell], color) + " ")
|
|
292
|
+
lines.append("".join(rendered).rstrip())
|
|
293
|
+
return "\n".join(lines)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def render_client_selector(
|
|
297
|
+
clients: list[ClientId],
|
|
298
|
+
*,
|
|
299
|
+
cursor: int,
|
|
300
|
+
selected: set[ClientId],
|
|
301
|
+
color: bool,
|
|
302
|
+
) -> str:
|
|
303
|
+
lines = [
|
|
304
|
+
paint("Select clients to configure:", LIME + BOLD, color),
|
|
305
|
+
paint("Use ↑/↓ to move, Space to select, Enter to continue.", TEAL, color),
|
|
306
|
+
]
|
|
307
|
+
for index, client in enumerate(clients):
|
|
308
|
+
pointer = "›" if index == cursor else " "
|
|
309
|
+
box = "[x]" if client in selected else "[ ]"
|
|
310
|
+
label = client.label
|
|
311
|
+
line = f"{pointer} {box} {label}"
|
|
312
|
+
if index == cursor:
|
|
313
|
+
line = paint(line, YELLOW + BOLD, color)
|
|
314
|
+
elif client in selected:
|
|
315
|
+
line = paint(line, CYAN, color)
|
|
316
|
+
lines.append(line)
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def interactive_client_selector(
|
|
321
|
+
*,
|
|
322
|
+
clients: list[ClientId],
|
|
323
|
+
default_selected: set[ClientId],
|
|
324
|
+
read_key,
|
|
325
|
+
write,
|
|
326
|
+
color: bool,
|
|
327
|
+
redraw: bool,
|
|
328
|
+
) -> list[ClientId]:
|
|
329
|
+
cursor = 0
|
|
330
|
+
selected = set(default_selected)
|
|
331
|
+
previous_lines = 0
|
|
332
|
+
|
|
333
|
+
if redraw:
|
|
334
|
+
write("\033[?25l")
|
|
335
|
+
try:
|
|
336
|
+
while True:
|
|
337
|
+
rendered = render_client_selector(
|
|
338
|
+
clients, cursor=cursor, selected=selected, color=color
|
|
339
|
+
)
|
|
340
|
+
if redraw and previous_lines:
|
|
341
|
+
write(f"\033[{previous_lines}F\033[J")
|
|
342
|
+
write(rendered)
|
|
343
|
+
write("\n")
|
|
344
|
+
previous_lines = rendered.count("\n") + 1
|
|
345
|
+
|
|
346
|
+
key = read_key()
|
|
347
|
+
if key == "up":
|
|
348
|
+
cursor = (cursor - 1) % len(clients)
|
|
349
|
+
elif key == "down":
|
|
350
|
+
cursor = (cursor + 1) % len(clients)
|
|
351
|
+
elif key == " ":
|
|
352
|
+
client = clients[cursor]
|
|
353
|
+
if client in selected:
|
|
354
|
+
selected.remove(client)
|
|
355
|
+
else:
|
|
356
|
+
selected.add(client)
|
|
357
|
+
elif key == "enter":
|
|
358
|
+
if not selected:
|
|
359
|
+
selected.add(clients[cursor])
|
|
360
|
+
return [client for client in clients if client in selected]
|
|
361
|
+
elif key == "\x03":
|
|
362
|
+
raise KeyboardInterrupt
|
|
363
|
+
finally:
|
|
364
|
+
if redraw:
|
|
365
|
+
write("\033[?25h")
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def read_terminal_key() -> str:
|
|
369
|
+
import termios
|
|
370
|
+
import tty
|
|
371
|
+
|
|
372
|
+
fd = sys.stdin.fileno()
|
|
373
|
+
old_settings = termios.tcgetattr(fd)
|
|
374
|
+
try:
|
|
375
|
+
tty.setraw(fd)
|
|
376
|
+
char = sys.stdin.read(1)
|
|
377
|
+
if char == "\x1b":
|
|
378
|
+
sequence = sys.stdin.read(2)
|
|
379
|
+
if sequence == "[A":
|
|
380
|
+
return "up"
|
|
381
|
+
if sequence == "[B":
|
|
382
|
+
return "down"
|
|
383
|
+
return char + sequence
|
|
384
|
+
if char in {"\r", "\n"}:
|
|
385
|
+
return "enter"
|
|
386
|
+
return char
|
|
387
|
+
finally:
|
|
388
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _setup_service() -> SetupService:
|
|
392
|
+
return SetupService(
|
|
393
|
+
mcp_gateway=HttpMcpGateway(),
|
|
394
|
+
client_configs=FileSystemClientConfigStore(),
|
|
395
|
+
skills=FileSystemSkillInstaller(),
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _doctor_service() -> DoctorService:
|
|
400
|
+
return DoctorService(
|
|
401
|
+
mcp_gateway=HttpMcpGateway(),
|
|
402
|
+
client_configs=FileSystemClientConfigStore(),
|
|
403
|
+
skills=FileSystemSkillInstaller(),
|
|
404
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ClientId(str, Enum):
|
|
8
|
+
CLAUDE_CODE = "claude-code"
|
|
9
|
+
CODEX = "codex"
|
|
10
|
+
CURSOR = "cursor"
|
|
11
|
+
VSCODE_COPILOT = "vscode-copilot"
|
|
12
|
+
OPENCODE = "opencode"
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def label(self) -> str:
|
|
16
|
+
return {
|
|
17
|
+
ClientId.CLAUDE_CODE: "Claude Code",
|
|
18
|
+
ClientId.CODEX: "Codex",
|
|
19
|
+
ClientId.CURSOR: "Cursor",
|
|
20
|
+
ClientId.VSCODE_COPILOT: "VS Code Copilot",
|
|
21
|
+
ClientId.OPENCODE: "OpenCode",
|
|
22
|
+
}[self]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
SUPPORTED_CLIENTS = [
|
|
26
|
+
ClientId.CLAUDE_CODE,
|
|
27
|
+
ClientId.CODEX,
|
|
28
|
+
ClientId.CURSOR,
|
|
29
|
+
ClientId.VSCODE_COPILOT,
|
|
30
|
+
ClientId.OPENCODE,
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class McpServerConfig:
|
|
36
|
+
endpoint: str
|
|
37
|
+
token: str
|
|
38
|
+
|
|
39
|
+
def normalized_endpoint(self) -> str:
|
|
40
|
+
endpoint = self.endpoint.strip()
|
|
41
|
+
if endpoint.endswith("/"):
|
|
42
|
+
return endpoint
|
|
43
|
+
return f"{endpoint}/"
|
|
44
|
+
|
|
45
|
+
def authorization_header(self) -> str:
|
|
46
|
+
return f"Bearer {self.token}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class SkillPackage:
|
|
51
|
+
name: str
|
|
52
|
+
version: str
|
|
53
|
+
content: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class DoctorReport:
|
|
58
|
+
ok: bool
|
|
59
|
+
token_present: bool
|
|
60
|
+
mcp_initialized: bool
|
|
61
|
+
required_tools_present: bool
|
|
62
|
+
repo_count: int
|
|
63
|
+
configured_clients: list[ClientId] = field(default_factory=list)
|
|
64
|
+
missing_config_clients: list[ClientId] = field(default_factory=list)
|
|
65
|
+
missing_skill_clients: list[ClientId] = field(default_factory=list)
|
|
66
|
+
message: str = ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SetupResult:
|
|
71
|
+
ok: bool
|
|
72
|
+
config: McpServerConfig
|
|
73
|
+
masked_token: str
|
|
74
|
+
skill_version: str
|
|
75
|
+
configured_clients: list[ClientId]
|
|
76
|
+
config_messages: list[str]
|
|
77
|
+
skill_messages: list[str]
|
|
78
|
+
doctor: DoctorReport
|
|
79
|
+
message: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def mask_token(token: str) -> str:
|
|
83
|
+
if len(token) <= 8:
|
|
84
|
+
return "****"
|
|
85
|
+
return f"{token[:4]}...{token[-4:]}"
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from unlegacy_cli.domain.models import ClientId, McpServerConfig, SkillPackage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class McpGateway(Protocol):
|
|
9
|
+
def validate(self, config: McpServerConfig) -> None:
|
|
10
|
+
"""Raise a ValueError with a user-facing message when auth fails."""
|
|
11
|
+
|
|
12
|
+
def list_tools(self, config: McpServerConfig) -> list[str]:
|
|
13
|
+
"""Return the MCP tool names advertised by the server."""
|
|
14
|
+
|
|
15
|
+
def list_repos(self, config: McpServerConfig) -> list[dict[str, object]]:
|
|
16
|
+
"""Call the Unlegacy list_repos tool and return parsed repo rows."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClientConfigStore(Protocol):
|
|
20
|
+
def configure(self, client: ClientId, config: McpServerConfig, *, dry_run: bool) -> str:
|
|
21
|
+
"""Write or print the MCP config for one selected client."""
|
|
22
|
+
|
|
23
|
+
def has_config(self, client: ClientId, config: McpServerConfig) -> bool:
|
|
24
|
+
"""Return whether the selected client appears configured."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SkillInstaller(Protocol):
|
|
28
|
+
def install(self, client: ClientId, *, dry_run: bool) -> str:
|
|
29
|
+
"""Install the packaged Unlegacy skill or instruction file."""
|
|
30
|
+
|
|
31
|
+
def is_installed(self, client: ClientId) -> bool:
|
|
32
|
+
"""Return whether the Unlegacy skill is detectable for the client."""
|
|
33
|
+
|
|
34
|
+
def version(self) -> str:
|
|
35
|
+
"""Return the packaged skill version."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SkillRepository(Protocol):
|
|
39
|
+
def load(self) -> SkillPackage:
|
|
40
|
+
"""Load and validate the packaged skill."""
|
|
@@ -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,15 @@
|
|
|
1
|
+
unlegacy_cli/__init__.py,sha256=VZTECcVMa5oztTgkvmv1gA4YywfEq6v1GYamDspoByM,51
|
|
2
|
+
unlegacy_cli/__main__.py,sha256=Ul2QTFxgrWJYi4cJ7AxPkUTDlGURvTR9HKGLMvYGUKA,92
|
|
3
|
+
unlegacy_cli/cli.py,sha256=dTkexk2-KMhXbw5_kjSdpPlwTeND6cTtQzQkL6c1Z58,13608
|
|
4
|
+
unlegacy_cli/adapters/client_configs.py,sha256=IuVoTL__L5lpb1NwKstNx3nKORoQsH_ceBuwp12yq3g,5650
|
|
5
|
+
unlegacy_cli/adapters/mcp_http.py,sha256=UOznmFQOnik0q8FDhHM1ScXBjfbWrilNcQz_MuSnDR8,5454
|
|
6
|
+
unlegacy_cli/adapters/skills.py,sha256=cRzU2WLjdhiqcHV6v8qWd3AbaEI9wPU2CMkPWjnt2vE,3301
|
|
7
|
+
unlegacy_cli/application/setup.py,sha256=zBC6qgZ2wAeAbFzW1BqMvj_HPEBPbwURBkZCSo3542s,5148
|
|
8
|
+
unlegacy_cli/assets/skills/unlegacy/SKILL.md,sha256=wjeeRR33K51hdhKbKvlivcFAIHdS7sazkRP8-my8uy8,2358
|
|
9
|
+
unlegacy_cli/domain/models.py,sha256=OGh6mntHcmituEiZKKOZIj0Jlaw5FcNsiWZ8CZaeOIM,1941
|
|
10
|
+
unlegacy_cli/domain/ports.py,sha256=awn6HFerRFPS78zZGcInkToIAqr9Xp4-NeRfMpTBCTM,1448
|
|
11
|
+
unlegacy_cli-0.1.0.dist-info/METADATA,sha256=8jHyibs4ZDIMajZRk_ZfoX5VIDVNXKIeqh-aPYbX68k,2586
|
|
12
|
+
unlegacy_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
13
|
+
unlegacy_cli-0.1.0.dist-info/entry_points.txt,sha256=6LAlPNIf1RpGLZobqzoKvWiO_HUzrVR4UH2RZ42kz7w,51
|
|
14
|
+
unlegacy_cli-0.1.0.dist-info/top_level.txt,sha256=3TqLUiM7lDviz2q5SfAPMTpbMn76uKWHoIigXK9sOnU,13
|
|
15
|
+
unlegacy_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
unlegacy_cli
|