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 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()