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.
@@ -0,0 +1,3 @@
1
+ """Unlegacy CLI package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from unlegacy_cli.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ unlegacy = unlegacy_cli.cli:main
@@ -0,0 +1 @@
1
+ unlegacy_cli