myopic 0.0.1__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.
- myopic/__init__.py +9 -0
- myopic/_wizard.py +144 -0
- myopic/ast_chunker.py +264 -0
- myopic/cli.py +148 -0
- myopic/config.py +145 -0
- myopic/diff.py +203 -0
- myopic/embeddings.py +54 -0
- myopic/platforms/__init__.py +1 -0
- myopic/platforms/base.py +134 -0
- myopic/platforms/gitlab.py +207 -0
- myopic/semantic/__init__.py +1 -0
- myopic/semantic/indexer.py +98 -0
- myopic/semantic/store.py +72 -0
- myopic/server.py +366 -0
- myopic/symbol_patterns.py +55 -0
- myopic/tools/__init__.py +6 -0
- myopic/tools/changed_files.py +89 -0
- myopic/tools/code_search.py +70 -0
- myopic/tools/dependency_impact.py +316 -0
- myopic/tools/diff_lines.py +167 -0
- myopic/tools/diff_sections.py +424 -0
- myopic/tools/index_repo.py +38 -0
- myopic/tools/review_context.py +183 -0
- myopic/tools/review_status.py +104 -0
- myopic/tools/trace_call_chain.py +329 -0
- myopic-0.0.1.dist-info/METADATA +182 -0
- myopic-0.0.1.dist-info/RECORD +30 -0
- myopic-0.0.1.dist-info/WHEEL +4 -0
- myopic-0.0.1.dist-info/entry_points.txt +5 -0
- myopic-0.0.1.dist-info/licenses/LICENSE +21 -0
myopic/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
myopic — the code-review MCP server with the most ironic name in the registry.
|
|
3
|
+
|
|
4
|
+
It's anything but nearsighted. It reviews your merge request against the *whole*
|
|
5
|
+
codebase — callers, conventions, duplication, dead code — not just the diff in
|
|
6
|
+
front of it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.0.1"
|
myopic/_wizard.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""
|
|
2
|
+
myopic setup wizard — interactive first-run configuration.
|
|
3
|
+
|
|
4
|
+
Mirrors amnesic's wizard UX: prompt for the connection, verify it live, then
|
|
5
|
+
persist it — the URL to config.toml (token referenced as ${GITLAB_TOKEN}) and
|
|
6
|
+
the token value to a sibling .env at chmod 600, so secrets never sit in the TOML.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
run_wizard(welcome) — interactive flow (prompt → test → save)
|
|
10
|
+
test_connection(url, token)— returns (ok, username_or_error)
|
|
11
|
+
write_config_toml(url) — write config.toml with ${GITLAB_TOKEN} reference
|
|
12
|
+
upsert_env_var(name, value)— insert/replace NAME=VALUE in .env, chmod 600
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
|
|
22
|
+
from myopic.config import config_dir, config_path, env_path, invalidate_config_cache
|
|
23
|
+
|
|
24
|
+
_CONFIG_TEMPLATE = """\
|
|
25
|
+
# myopic config.toml
|
|
26
|
+
# Documentation: https://github.com/SurajKGoyal/myopic
|
|
27
|
+
#
|
|
28
|
+
# The token is kept OUT of this file — it's referenced as ${{GITLAB_TOKEN}} and
|
|
29
|
+
# its value lives in the sibling .env (chmod 600). Edit the URL here if your
|
|
30
|
+
# GitLab instance moves; rotate the token with `myopic set-secret`.
|
|
31
|
+
|
|
32
|
+
[gitlab]
|
|
33
|
+
url = "{url}"
|
|
34
|
+
token = "${{GITLAB_TOKEN}}"
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _token_hint(url: str) -> str:
|
|
39
|
+
return f"{url.rstrip('/')}/-/user_settings/personal_access_tokens"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def secure_file(path: Path) -> None:
|
|
43
|
+
"""Best-effort chmod 600 — owner read/write only. No-op where unsupported."""
|
|
44
|
+
if os.name == "posix":
|
|
45
|
+
try:
|
|
46
|
+
os.chmod(path, 0o600)
|
|
47
|
+
except OSError:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_connection(url: str, token: str) -> tuple[bool, str]:
|
|
52
|
+
"""Authenticate to GitLab. Returns (ok, username) or (False, error_message)."""
|
|
53
|
+
try:
|
|
54
|
+
import gitlab
|
|
55
|
+
|
|
56
|
+
client = gitlab.Gitlab(url=url, private_token=token)
|
|
57
|
+
client.auth()
|
|
58
|
+
user = client.user
|
|
59
|
+
return True, getattr(user, "username", "?") if user else "?"
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
# Never surface the raw error — some python-gitlab versions echo the token.
|
|
62
|
+
first = str(exc).splitlines()[0][:120]
|
|
63
|
+
redacted = first.replace(token, "***") if token else first
|
|
64
|
+
return False, redacted
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_config_toml(url: str) -> None:
|
|
68
|
+
"""Write config.toml with the URL and a ${GITLAB_TOKEN} reference."""
|
|
69
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
70
|
+
config_path().write_text(_CONFIG_TEMPLATE.format(url=url.rstrip("/")), encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def upsert_env_var(name: str, value: str) -> None:
|
|
74
|
+
"""Insert or replace NAME=VALUE in the .env, preserving other lines. chmod 600."""
|
|
75
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path = env_path()
|
|
77
|
+
|
|
78
|
+
lines = path.read_text(encoding="utf-8").splitlines() if path.is_file() else []
|
|
79
|
+
out: list[str] = []
|
|
80
|
+
replaced = False
|
|
81
|
+
for line in lines:
|
|
82
|
+
stripped = line.strip()
|
|
83
|
+
if (
|
|
84
|
+
stripped and not stripped.startswith("#") and "=" in stripped
|
|
85
|
+
and stripped.split("=", 1)[0].strip() == name
|
|
86
|
+
):
|
|
87
|
+
out.append(f"{name}={value}")
|
|
88
|
+
replaced = True
|
|
89
|
+
else:
|
|
90
|
+
out.append(line)
|
|
91
|
+
if not replaced:
|
|
92
|
+
out.append(f"{name}={value}")
|
|
93
|
+
|
|
94
|
+
content = "\n".join(out)
|
|
95
|
+
if content and not content.endswith("\n"):
|
|
96
|
+
content += "\n"
|
|
97
|
+
path.write_text(content, encoding="utf-8")
|
|
98
|
+
secure_file(path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def run_wizard(welcome: bool = True) -> None:
|
|
102
|
+
"""Interactive GitLab setup: prompt → verify live → persist config + secret."""
|
|
103
|
+
config_dir().mkdir(parents=True, exist_ok=True)
|
|
104
|
+
|
|
105
|
+
if welcome:
|
|
106
|
+
click.echo()
|
|
107
|
+
click.echo("Welcome to myopic — the code-review MCP that sees the whole codebase.")
|
|
108
|
+
click.echo("Let's connect it to your GitLab.")
|
|
109
|
+
click.echo()
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
url = click.prompt("? GitLab URL", default="https://gitlab.com").rstrip("/")
|
|
113
|
+
click.echo()
|
|
114
|
+
click.echo(" Create a personal access token with 'api' (or 'read_api') scope:")
|
|
115
|
+
click.echo(f" {_token_hint(url)}")
|
|
116
|
+
token = click.prompt("? Personal access token (hidden)", hide_input=True)
|
|
117
|
+
|
|
118
|
+
click.echo()
|
|
119
|
+
click.echo(" Testing connection...", nl=False)
|
|
120
|
+
ok, info = test_connection(url, token)
|
|
121
|
+
|
|
122
|
+
if ok:
|
|
123
|
+
click.echo(f" ✓ Authenticated as {info}.")
|
|
124
|
+
click.echo()
|
|
125
|
+
write_config_toml(url)
|
|
126
|
+
click.echo(f" ✓ URL saved to {config_path()}")
|
|
127
|
+
upsert_env_var("GITLAB_TOKEN", token)
|
|
128
|
+
click.echo(f" ✓ Token saved to {env_path()} (chmod 600)")
|
|
129
|
+
invalidate_config_cache()
|
|
130
|
+
break
|
|
131
|
+
|
|
132
|
+
click.echo(" ✗ Failed.")
|
|
133
|
+
click.echo(f" Error: {info}")
|
|
134
|
+
click.echo()
|
|
135
|
+
if not click.confirm("? Try again with different details?", default=True):
|
|
136
|
+
raise SystemExit(1)
|
|
137
|
+
click.echo()
|
|
138
|
+
|
|
139
|
+
click.echo()
|
|
140
|
+
click.echo("Next steps:")
|
|
141
|
+
click.echo(" 1. Run `myopic test` to re-verify the connection any time.")
|
|
142
|
+
click.echo(" 2. Add myopic to your MCP client config (see the README snippet).")
|
|
143
|
+
click.echo(" 3. Ask your AI client to review a merge request URL.")
|
|
144
|
+
click.echo()
|
myopic/ast_chunker.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST-aware code chunking using tree-sitter.
|
|
3
|
+
|
|
4
|
+
Splits code by semantic boundaries (functions, classes, methods) instead of
|
|
5
|
+
fixed line windows. Falls back gracefully when tree-sitter grammars are
|
|
6
|
+
unavailable or parsing fails.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
MAX_CHUNK_CHARS = 4_000
|
|
17
|
+
|
|
18
|
+
# Mapping from our language names to tree-sitter module names + node types
|
|
19
|
+
_LANGUAGE_CONFIG: dict[str, dict] = {
|
|
20
|
+
"python": {
|
|
21
|
+
"module": "tree_sitter_python",
|
|
22
|
+
"top_nodes": {
|
|
23
|
+
"function_definition", "class_definition", "decorated_definition",
|
|
24
|
+
},
|
|
25
|
+
"sub_nodes": {"function_definition"}, # methods inside classes
|
|
26
|
+
},
|
|
27
|
+
"javascript": {
|
|
28
|
+
"module": "tree_sitter_javascript",
|
|
29
|
+
"top_nodes": {
|
|
30
|
+
"function_declaration", "class_declaration", "export_statement",
|
|
31
|
+
"lexical_declaration", "expression_statement",
|
|
32
|
+
},
|
|
33
|
+
"sub_nodes": {"method_definition", "function_declaration"},
|
|
34
|
+
},
|
|
35
|
+
"typescript": {
|
|
36
|
+
"module": "tree_sitter_typescript",
|
|
37
|
+
"lang_attr": "language_typescript",
|
|
38
|
+
"top_nodes": {
|
|
39
|
+
"function_declaration", "class_declaration", "export_statement",
|
|
40
|
+
"lexical_declaration", "interface_declaration", "type_alias_declaration",
|
|
41
|
+
"expression_statement", "enum_declaration",
|
|
42
|
+
},
|
|
43
|
+
"sub_nodes": {"method_definition", "function_declaration"},
|
|
44
|
+
},
|
|
45
|
+
"java": {
|
|
46
|
+
"module": "tree_sitter_java",
|
|
47
|
+
"top_nodes": {
|
|
48
|
+
"class_declaration", "interface_declaration", "enum_declaration",
|
|
49
|
+
"method_declaration", "import_declaration",
|
|
50
|
+
},
|
|
51
|
+
"sub_nodes": {"method_declaration", "constructor_declaration"},
|
|
52
|
+
},
|
|
53
|
+
"go": {
|
|
54
|
+
"module": "tree_sitter_go",
|
|
55
|
+
"top_nodes": {
|
|
56
|
+
"function_declaration", "method_declaration", "type_declaration",
|
|
57
|
+
},
|
|
58
|
+
"sub_nodes": set(),
|
|
59
|
+
},
|
|
60
|
+
"rust": {
|
|
61
|
+
"module": "tree_sitter_rust",
|
|
62
|
+
"top_nodes": {
|
|
63
|
+
"function_item", "impl_item", "struct_item", "enum_item",
|
|
64
|
+
"trait_item", "mod_item",
|
|
65
|
+
},
|
|
66
|
+
"sub_nodes": {"function_item"},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# Cache parsed languages to avoid re-importing
|
|
71
|
+
_parser_cache: dict[str, object] = {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _get_parser(language: str):
|
|
75
|
+
"""Get or create a tree-sitter parser for the given language."""
|
|
76
|
+
if language in _parser_cache:
|
|
77
|
+
return _parser_cache[language]
|
|
78
|
+
|
|
79
|
+
config = _LANGUAGE_CONFIG.get(language)
|
|
80
|
+
if not config:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
import importlib
|
|
85
|
+
|
|
86
|
+
import tree_sitter
|
|
87
|
+
|
|
88
|
+
mod = importlib.import_module(config["module"])
|
|
89
|
+
lang_attr = config.get("lang_attr", "language")
|
|
90
|
+
lang_fn = getattr(mod, lang_attr, None)
|
|
91
|
+
if lang_fn is None:
|
|
92
|
+
# some grammars export language() as a function
|
|
93
|
+
lang_fn = getattr(mod, "language", None)
|
|
94
|
+
if lang_fn is None:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
raw_lang = lang_fn()
|
|
98
|
+
# tree-sitter >=0.24 returns PyCapsule from language(), wrap it
|
|
99
|
+
if not isinstance(raw_lang, tree_sitter.Language):
|
|
100
|
+
lang_obj = tree_sitter.Language(raw_lang)
|
|
101
|
+
else:
|
|
102
|
+
lang_obj = raw_lang
|
|
103
|
+
parser = tree_sitter.Parser(lang_obj)
|
|
104
|
+
_parser_cache[language] = (parser, config)
|
|
105
|
+
return (parser, config)
|
|
106
|
+
except (ImportError, AttributeError, Exception) as e:
|
|
107
|
+
logger.debug("tree-sitter unavailable for %s: %s", language, e)
|
|
108
|
+
_parser_cache[language] = None
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _extract_symbol_name(node) -> Optional[str]:
|
|
113
|
+
"""Extract the name of a function/class/method from an AST node."""
|
|
114
|
+
# Look for a 'name' or 'identifier' child
|
|
115
|
+
for child in node.children:
|
|
116
|
+
if child.type in ("identifier", "name", "property_identifier", "type_identifier"):
|
|
117
|
+
return child.text.decode("utf-8", errors="replace")
|
|
118
|
+
# For decorated_definition / export_statement, recurse into the actual definition
|
|
119
|
+
for child in node.children:
|
|
120
|
+
if "definition" in child.type or "declaration" in child.type:
|
|
121
|
+
return _extract_symbol_name(child)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _node_symbol_type(node) -> str:
|
|
126
|
+
"""Classify a node as 'class', 'function', 'method', 'interface', or 'other'."""
|
|
127
|
+
t = node.type
|
|
128
|
+
if "class" in t:
|
|
129
|
+
return "class"
|
|
130
|
+
if "interface" in t:
|
|
131
|
+
return "interface"
|
|
132
|
+
if "method" in t or "constructor" in t:
|
|
133
|
+
return "method"
|
|
134
|
+
if "function" in t:
|
|
135
|
+
return "function"
|
|
136
|
+
if "enum" in t:
|
|
137
|
+
return "enum"
|
|
138
|
+
if "impl" in t or "trait" in t:
|
|
139
|
+
return "trait"
|
|
140
|
+
return "other"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def ast_chunk(
|
|
144
|
+
content: str,
|
|
145
|
+
language: str,
|
|
146
|
+
) -> Optional[list[tuple[str, int, int, Optional[str], str]]]:
|
|
147
|
+
"""
|
|
148
|
+
Split content by AST boundaries.
|
|
149
|
+
|
|
150
|
+
Returns list of (chunk_text, start_line_1indexed, end_line_1indexed, symbol_name, symbol_type)
|
|
151
|
+
or None if tree-sitter is unavailable / parsing fails.
|
|
152
|
+
"""
|
|
153
|
+
result = _get_parser(language)
|
|
154
|
+
if result is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
parser, config = result
|
|
158
|
+
top_nodes = config["top_nodes"]
|
|
159
|
+
sub_nodes = config.get("sub_nodes", set())
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
tree = parser.parse(content.encode("utf-8"))
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.debug("tree-sitter parse failed for %s: %s", language, e)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
lines = content.split("\n")
|
|
168
|
+
chunks: list[tuple[str, int, int, Optional[str], str]] = []
|
|
169
|
+
|
|
170
|
+
def _extract_node(node, is_sub: bool = False):
|
|
171
|
+
"""Extract a node as a chunk, potentially splitting large nodes."""
|
|
172
|
+
start_line = node.start_point[0] # 0-indexed
|
|
173
|
+
end_line = node.end_point[0] # 0-indexed
|
|
174
|
+
text = "\n".join(lines[start_line:end_line + 1])
|
|
175
|
+
name = _extract_symbol_name(node)
|
|
176
|
+
sym_type = _node_symbol_type(node)
|
|
177
|
+
|
|
178
|
+
if len(text) <= MAX_CHUNK_CHARS:
|
|
179
|
+
chunks.append((text, start_line + 1, end_line + 1, name, sym_type))
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
# Large node — try to split by sub-nodes (e.g., methods in a class)
|
|
183
|
+
if not is_sub and sub_nodes:
|
|
184
|
+
sub_children = [c for c in node.children if c.type in sub_nodes]
|
|
185
|
+
if sub_children:
|
|
186
|
+
# Add class header (everything before first method)
|
|
187
|
+
first_sub_start = sub_children[0].start_point[0]
|
|
188
|
+
if first_sub_start > start_line:
|
|
189
|
+
header = "\n".join(lines[start_line:first_sub_start])
|
|
190
|
+
if header.strip() and len(header) <= MAX_CHUNK_CHARS:
|
|
191
|
+
chunks.append((header, start_line + 1, first_sub_start, name, sym_type))
|
|
192
|
+
|
|
193
|
+
for sub in sub_children:
|
|
194
|
+
_extract_node(sub, is_sub=True)
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
# Fallback: split oversized node into MAX_CHUNK_CHARS pieces
|
|
198
|
+
chunk_lines = []
|
|
199
|
+
chunk_start = start_line
|
|
200
|
+
current_len = 0
|
|
201
|
+
for i in range(start_line, end_line + 1):
|
|
202
|
+
line = lines[i] if i < len(lines) else ""
|
|
203
|
+
if current_len + len(line) + 1 > MAX_CHUNK_CHARS and chunk_lines:
|
|
204
|
+
chunks.append((
|
|
205
|
+
"\n".join(chunk_lines),
|
|
206
|
+
chunk_start + 1,
|
|
207
|
+
chunk_start + len(chunk_lines),
|
|
208
|
+
name,
|
|
209
|
+
sym_type,
|
|
210
|
+
))
|
|
211
|
+
chunk_lines = []
|
|
212
|
+
chunk_start = i
|
|
213
|
+
current_len = 0
|
|
214
|
+
chunk_lines.append(line)
|
|
215
|
+
current_len += len(line) + 1
|
|
216
|
+
|
|
217
|
+
if chunk_lines:
|
|
218
|
+
chunks.append((
|
|
219
|
+
"\n".join(chunk_lines),
|
|
220
|
+
chunk_start + 1,
|
|
221
|
+
chunk_start + len(chunk_lines),
|
|
222
|
+
name,
|
|
223
|
+
sym_type,
|
|
224
|
+
))
|
|
225
|
+
|
|
226
|
+
# Collect top-level nodes
|
|
227
|
+
root = tree.root_node
|
|
228
|
+
collected_ranges: list[tuple[int, int]] = []
|
|
229
|
+
|
|
230
|
+
for child in root.children:
|
|
231
|
+
if child.type in top_nodes:
|
|
232
|
+
_extract_node(child)
|
|
233
|
+
collected_ranges.append((child.start_point[0], child.end_point[0]))
|
|
234
|
+
|
|
235
|
+
# Merge uncollected lines (imports, module-level code) into a preamble chunk
|
|
236
|
+
if collected_ranges and lines:
|
|
237
|
+
collected_ranges.sort()
|
|
238
|
+
uncollected_lines = []
|
|
239
|
+
uncollected_start = 0
|
|
240
|
+
|
|
241
|
+
for rng_start, rng_end in collected_ranges:
|
|
242
|
+
for i in range(uncollected_start, rng_start):
|
|
243
|
+
if i < len(lines) and lines[i].strip():
|
|
244
|
+
uncollected_lines.append((i, lines[i]))
|
|
245
|
+
uncollected_start = rng_end + 1
|
|
246
|
+
|
|
247
|
+
# Trailing lines after last collected node
|
|
248
|
+
for i in range(uncollected_start, len(lines)):
|
|
249
|
+
if lines[i].strip():
|
|
250
|
+
uncollected_lines.append((i, lines[i]))
|
|
251
|
+
|
|
252
|
+
if uncollected_lines:
|
|
253
|
+
preamble_text = "\n".join(l[1] for l in uncollected_lines)
|
|
254
|
+
if len(preamble_text) <= MAX_CHUNK_CHARS and preamble_text.strip():
|
|
255
|
+
first_line = uncollected_lines[0][0] + 1
|
|
256
|
+
last_line = uncollected_lines[-1][0] + 1
|
|
257
|
+
chunks.append((preamble_text, first_line, last_line, None, "preamble"))
|
|
258
|
+
|
|
259
|
+
if not chunks:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Sort by start line
|
|
263
|
+
chunks.sort(key=lambda c: c[1])
|
|
264
|
+
return chunks
|
myopic/cli.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
myopic CLI
|
|
3
|
+
|
|
4
|
+
Commands:
|
|
5
|
+
myopic init — interactive setup wizard (prompt → test → save)
|
|
6
|
+
myopic init --template — write a blank config template for hand-editing
|
|
7
|
+
myopic set-secret — set or rotate the GitLab token (hidden input)
|
|
8
|
+
myopic test — verify the configured GitLab connection
|
|
9
|
+
(no subcommand) — start the MCP server when launched by a client
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from myopic.config import config_dir, config_path
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
_TOKEN_HINT = "https://gitlab.com/-/user_settings/personal_access_tokens"
|
|
22
|
+
|
|
23
|
+
_TEMPLATE = """\
|
|
24
|
+
# myopic config.toml
|
|
25
|
+
# Documentation: https://github.com/SurajKGoyal/myopic
|
|
26
|
+
#
|
|
27
|
+
# myopic needs a GitLab URL and a personal access token with `api` (or at least
|
|
28
|
+
# `read_api`) scope. Keep the token OUT of this file — reference it via ${VAR}
|
|
29
|
+
# and put the value in a sibling .env file (config dir / .env), or export it in
|
|
30
|
+
# your environment.
|
|
31
|
+
|
|
32
|
+
[gitlab]
|
|
33
|
+
url = "https://gitlab.com"
|
|
34
|
+
token = "${GITLAB_TOKEN}"
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group(invoke_without_command=True)
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def cli(ctx: click.Context) -> None:
|
|
41
|
+
"""myopic — the code-review MCP that sees the whole codebase, not just the diff.
|
|
42
|
+
|
|
43
|
+
With no subcommand:
|
|
44
|
+
• If stdin is piped (an MCP client launched us) → start the MCP server.
|
|
45
|
+
• If stdin is a TTY (you ran 'myopic' in your terminal) → show this help.
|
|
46
|
+
"""
|
|
47
|
+
if ctx.invoked_subcommand is not None:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
import sys
|
|
51
|
+
|
|
52
|
+
if sys.stdin.isatty():
|
|
53
|
+
click.echo(ctx.get_help())
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
from myopic.server import main as _server_main
|
|
57
|
+
|
|
58
|
+
_server_main()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@cli.command()
|
|
62
|
+
@click.option("--template", is_flag=True, default=False,
|
|
63
|
+
help="Write a blank config template for hand-editing instead of running the wizard.")
|
|
64
|
+
def init(template: bool) -> None:
|
|
65
|
+
"""Set up myopic interactively (wizard). Pass --template to hand-edit instead.
|
|
66
|
+
|
|
67
|
+
The wizard prompts for your GitLab URL and token, verifies the connection
|
|
68
|
+
live, then saves the URL to config.toml and the token to a sibling .env
|
|
69
|
+
(chmod 600) so the secret never lives in the TOML.
|
|
70
|
+
"""
|
|
71
|
+
cfg_dir = config_dir()
|
|
72
|
+
cfg_file = config_path()
|
|
73
|
+
|
|
74
|
+
if template:
|
|
75
|
+
if cfg_file.exists():
|
|
76
|
+
console.print(
|
|
77
|
+
f"[yellow]Config already exists:[/yellow] {cfg_file}\n"
|
|
78
|
+
f"Edit it directly, or delete it and re-run."
|
|
79
|
+
)
|
|
80
|
+
raise SystemExit(0)
|
|
81
|
+
cfg_dir.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
cfg_file.write_text(_TEMPLATE, encoding="utf-8")
|
|
83
|
+
console.print(f"[green]Created:[/green] {cfg_file}")
|
|
84
|
+
console.print()
|
|
85
|
+
console.print("[bold]Next steps:[/bold]")
|
|
86
|
+
console.print(f" 1. Create a GitLab token (api scope): [cyan]{_TOKEN_HINT}[/cyan]")
|
|
87
|
+
console.print(f" 2. Put it in [cyan]{cfg_dir / '.env'}[/cyan] as [cyan]GITLAB_TOKEN=...[/cyan]")
|
|
88
|
+
console.print(f" 3. Edit [cyan]{cfg_file}[/cyan] if your GitLab URL is self-hosted")
|
|
89
|
+
console.print(f" 4. Run [cyan]myopic test[/cyan] to verify the connection")
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if cfg_file.exists():
|
|
93
|
+
if not click.confirm(
|
|
94
|
+
f"Config already exists at {cfg_file}. Reconfigure?", default=False
|
|
95
|
+
):
|
|
96
|
+
console.print(
|
|
97
|
+
"Left unchanged. Use [cyan]myopic set-secret[/cyan] to rotate just the token."
|
|
98
|
+
)
|
|
99
|
+
raise SystemExit(0)
|
|
100
|
+
|
|
101
|
+
from myopic._wizard import run_wizard
|
|
102
|
+
run_wizard(welcome=True)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@cli.command("set-secret")
|
|
106
|
+
def set_secret() -> None:
|
|
107
|
+
"""Set or rotate the GitLab token in ~/.config/myopic/.env (hidden input)."""
|
|
108
|
+
value = click.prompt("GitLab token", hide_input=True, confirmation_prompt=True)
|
|
109
|
+
|
|
110
|
+
from myopic._wizard import upsert_env_var
|
|
111
|
+
from myopic.config import invalidate_config_cache
|
|
112
|
+
|
|
113
|
+
upsert_env_var("GITLAB_TOKEN", value)
|
|
114
|
+
invalidate_config_cache()
|
|
115
|
+
console.print(
|
|
116
|
+
f"[green]✓[/green] Saved GITLAB_TOKEN to {config_dir() / '.env'} (chmod 600)"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@cli.command()
|
|
121
|
+
def test() -> None:
|
|
122
|
+
"""Verify that the configured GitLab URL + token authenticate successfully."""
|
|
123
|
+
from myopic.config import load_config
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
cfg = load_config()
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
console.print(f"[red]Config error:[/red] {exc}")
|
|
129
|
+
raise SystemExit(1) from exc
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
import gitlab
|
|
133
|
+
|
|
134
|
+
client = gitlab.Gitlab(url=cfg.url, private_token=cfg.token)
|
|
135
|
+
client.auth()
|
|
136
|
+
user = client.user
|
|
137
|
+
username = getattr(user, "username", "?") if user else "?"
|
|
138
|
+
console.print(
|
|
139
|
+
f"[green]✓[/green] Authenticated to [cyan]{cfg.url}[/cyan] as [bold]{username}[/bold]"
|
|
140
|
+
)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
msg = str(exc).splitlines()[0][:120]
|
|
143
|
+
console.print(f"[red]✗[/red] Could not authenticate to {cfg.url}: [red]{msg}[/red]")
|
|
144
|
+
raise SystemExit(1) from exc
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
cli()
|