sql-code-graph 0.2.1__py3-none-any.whl → 1.0.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.
- sql_code_graph-1.0.0.dist-info/METADATA +295 -0
- sql_code_graph-1.0.0.dist-info/RECORD +63 -0
- sqlcg/__init__.py +1 -1
- sqlcg/cli/commands/analyze.py +24 -0
- sqlcg/cli/commands/db.py +86 -5
- sqlcg/cli/commands/gain.py +74 -14
- sqlcg/cli/commands/git.py +71 -40
- sqlcg/cli/commands/index.py +127 -17
- sqlcg/cli/commands/install.py +165 -12
- sqlcg/cli/commands/mcp.py +13 -0
- sqlcg/cli/commands/reindex.py +170 -0
- sqlcg/cli/commands/uninstall.py +268 -0
- sqlcg/cli/commands/watch.py +14 -1
- sqlcg/cli/main.py +33 -2
- sqlcg/core/config.py +185 -2
- sqlcg/core/graph_db.py +65 -0
- sqlcg/core/kuzu_backend.py +199 -26
- sqlcg/core/neo4j_backend.py +38 -0
- sqlcg/core/queries.cypher +114 -0
- sqlcg/core/queries.py +44 -82
- sqlcg/core/schema.cypher +15 -3
- sqlcg/core/schema.py +2 -1
- sqlcg/indexer/error_classify.py +140 -0
- sqlcg/indexer/git_delta.py +121 -0
- sqlcg/indexer/indexer.py +957 -112
- sqlcg/indexer/pool.py +446 -0
- sqlcg/indexer/walker.py +1 -3
- sqlcg/indexer/watcher.py +68 -18
- sqlcg/lineage/aggregator.py +58 -2
- sqlcg/lineage/schema_resolver.py +26 -14
- sqlcg/parsers/ansi_parser.py +210 -24
- sqlcg/parsers/base.py +620 -54
- sqlcg/parsers/bigquery_parser.py +9 -4
- sqlcg/parsers/postgres_parser.py +7 -2
- sqlcg/parsers/registry.py +7 -2
- sqlcg/parsers/snowflake_parser.py +173 -10
- sqlcg/parsers/tsql_parser.py +7 -2
- sqlcg/server/models.py +338 -1
- sqlcg/server/noise_filter.py +167 -0
- sqlcg/server/skill.py +256 -0
- sqlcg/server/tools.py +1036 -147
- sql_code_graph-0.2.1.dist-info/METADATA +0 -171
- sql_code_graph-0.2.1.dist-info/RECORD +0 -55
- {sql_code_graph-0.2.1.dist-info → sql_code_graph-1.0.0.dist-info}/WHEEL +0 -0
- {sql_code_graph-0.2.1.dist-info → sql_code_graph-1.0.0.dist-info}/entry_points.txt +0 -0
sqlcg/cli/commands/git.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Git integration commands for sqlcg."""
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
+
from typing import NamedTuple
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
from rich.console import Console
|
|
@@ -10,6 +11,71 @@ console = Console()
|
|
|
10
11
|
app = typer.Typer(name="git", help="Git integration commands")
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
class _HookSpec(NamedTuple):
|
|
15
|
+
filename: str
|
|
16
|
+
sentinel: str
|
|
17
|
+
script: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_HOOKS: list[_HookSpec] = [
|
|
21
|
+
_HookSpec(
|
|
22
|
+
filename="post-checkout",
|
|
23
|
+
sentinel="# sqlcg post-checkout hook",
|
|
24
|
+
script=(
|
|
25
|
+
"#!/bin/sh\n"
|
|
26
|
+
"# sqlcg post-checkout hook — incremental resync after branch switch\n"
|
|
27
|
+
"# $3 == 1 means branch checkout (not file checkout); skip file checkouts\n"
|
|
28
|
+
'[ "$3" = "1" ] || exit 0\n'
|
|
29
|
+
'sqlcg reindex --from "$1" --to "$2"'
|
|
30
|
+
' "$(git rev-parse --show-toplevel)" --dialect auto --quiet || true\n'
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
_HookSpec(
|
|
34
|
+
filename="post-merge",
|
|
35
|
+
sentinel="# sqlcg post-merge hook",
|
|
36
|
+
script="""\
|
|
37
|
+
#!/bin/sh
|
|
38
|
+
# sqlcg post-merge hook — incremental resync after pull/merge
|
|
39
|
+
# post-merge receives only $1 (squash flag), no old/new SHA; use stored-SHA delta
|
|
40
|
+
sqlcg reindex "$(git rev-parse --show-toplevel)" --dialect auto --quiet || true
|
|
41
|
+
""",
|
|
42
|
+
),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _install_single_hook(hooks_dir: Path, spec: _HookSpec) -> None:
|
|
47
|
+
"""Install one git hook idempotently.
|
|
48
|
+
|
|
49
|
+
If the hook file already contains the sentinel, it is already installed — skip silently.
|
|
50
|
+
If the hook file exists without the sentinel, warn and print the script for manual append.
|
|
51
|
+
Otherwise, write the hook file and set 0o755.
|
|
52
|
+
"""
|
|
53
|
+
hook_path = hooks_dir / spec.filename
|
|
54
|
+
|
|
55
|
+
if hook_path.exists():
|
|
56
|
+
existing_content = hook_path.read_text()
|
|
57
|
+
if spec.sentinel in existing_content:
|
|
58
|
+
# Already installed — idempotent, skip silently
|
|
59
|
+
return
|
|
60
|
+
else:
|
|
61
|
+
# Foreign hook without sqlcg sentinel
|
|
62
|
+
console.print(
|
|
63
|
+
f"[yellow]Warning: existing {spec.filename} hook found that was not created "
|
|
64
|
+
"by sqlcg.[/yellow]"
|
|
65
|
+
)
|
|
66
|
+
console.print(
|
|
67
|
+
f"[yellow]To integrate sqlcg, manually append the following to "
|
|
68
|
+
f".git/hooks/{spec.filename}:[/yellow]"
|
|
69
|
+
)
|
|
70
|
+
console.print("")
|
|
71
|
+
console.print("[cyan]" + spec.script.rstrip() + "[/cyan]")
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
hook_path.write_text(spec.script)
|
|
75
|
+
hook_path.chmod(0o755)
|
|
76
|
+
console.print(f"[green]Installed git hook:[/green] .git/hooks/{spec.filename}")
|
|
77
|
+
|
|
78
|
+
|
|
13
79
|
@app.command("install-hooks")
|
|
14
80
|
def install_hooks(
|
|
15
81
|
repo: Path | None = typer.Option( # noqa: B008
|
|
@@ -18,8 +84,9 @@ def install_hooks(
|
|
|
18
84
|
) -> None:
|
|
19
85
|
"""Install git hooks for sqlcg integration.
|
|
20
86
|
|
|
21
|
-
Writes a post-checkout hook that triggers
|
|
22
|
-
|
|
87
|
+
Writes a post-checkout hook that triggers incremental resync after branch switches
|
|
88
|
+
and a post-merge hook that triggers resync after pulls/merges.
|
|
89
|
+
Idempotent: running multiple times produces one hook entry per hook.
|
|
23
90
|
"""
|
|
24
91
|
if repo is None:
|
|
25
92
|
repo = Path.cwd()
|
|
@@ -33,41 +100,5 @@ def install_hooks(
|
|
|
33
100
|
|
|
34
101
|
hooks_dir.mkdir(parents=True, exist_ok=True)
|
|
35
102
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
# Hook script content
|
|
40
|
-
hook_script = """#!/bin/sh
|
|
41
|
-
# sqlcg post-checkout hook — resync graph after branch switch
|
|
42
|
-
# $3 == 1 means branch checkout (not file checkout); skip file checkouts
|
|
43
|
-
[ "$3" = "1" ] || exit 0
|
|
44
|
-
sqlcg index "$(git rev-parse --show-toplevel)" --dialect auto --quiet || true
|
|
45
|
-
"""
|
|
46
|
-
|
|
47
|
-
# Check if hook already exists
|
|
48
|
-
if hook_path.exists():
|
|
49
|
-
existing_content = hook_path.read_text()
|
|
50
|
-
if hook_sentinel in existing_content:
|
|
51
|
-
# Already installed, idempotent: skip silently
|
|
52
|
-
return
|
|
53
|
-
else:
|
|
54
|
-
# Existing hook without sqlcg sentinel
|
|
55
|
-
console.print(
|
|
56
|
-
"[yellow]Warning: existing post-checkout hook found that was not created "
|
|
57
|
-
"by sqlcg.[/yellow]"
|
|
58
|
-
)
|
|
59
|
-
console.print(
|
|
60
|
-
"[yellow]To integrate sqlcg, manually append the following to "
|
|
61
|
-
".git/hooks/post-checkout:[/yellow]"
|
|
62
|
-
)
|
|
63
|
-
console.print("")
|
|
64
|
-
console.print("[cyan]" + hook_script.rstrip() + "[/cyan]")
|
|
65
|
-
return
|
|
66
|
-
|
|
67
|
-
# Write hook script
|
|
68
|
-
hook_path.write_text(hook_script)
|
|
69
|
-
|
|
70
|
-
# Make it executable
|
|
71
|
-
hook_path.chmod(0o755)
|
|
72
|
-
|
|
73
|
-
console.print("[green]Installed git hook:[/green] .git/hooks/post-checkout")
|
|
103
|
+
for spec in _HOOKS:
|
|
104
|
+
_install_single_hook(hooks_dir, spec)
|
sqlcg/cli/commands/index.py
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
"""Index command for scanning and indexing SQL files."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
import typer
|
|
6
7
|
from rich.console import Console
|
|
8
|
+
from rich.progress import (
|
|
9
|
+
BarColumn,
|
|
10
|
+
MofNCompleteColumn,
|
|
11
|
+
Progress,
|
|
12
|
+
SpinnerColumn,
|
|
13
|
+
TextColumn,
|
|
14
|
+
TimeRemainingColumn,
|
|
15
|
+
)
|
|
7
16
|
|
|
8
17
|
from sqlcg.core.config import get_backend, get_db_path, get_dialect
|
|
9
18
|
from sqlcg.indexer.indexer import Indexer
|
|
@@ -20,26 +29,53 @@ def index_cmd( # noqa: B008
|
|
|
20
29
|
None, "--dbt-manifest", help="Path to dbt manifest"
|
|
21
30
|
),
|
|
22
31
|
timeout_per_file: int = typer.Option( # noqa: B008
|
|
23
|
-
|
|
32
|
+
5, "--timeout-per-file", help="Timeout per file in seconds"
|
|
24
33
|
),
|
|
25
|
-
|
|
26
|
-
|
|
34
|
+
buffer_pool_size: int = typer.Option( # noqa: B008
|
|
35
|
+
0,
|
|
36
|
+
"--buffer-pool-size",
|
|
37
|
+
help="KuzuDB buffer pool size in MB (0 = default). "
|
|
38
|
+
"Set to 256-512 on memory-constrained machines.",
|
|
39
|
+
),
|
|
40
|
+
batch_size: int = typer.Option( # noqa: B008
|
|
41
|
+
50,
|
|
42
|
+
"--batch-size",
|
|
43
|
+
help=(
|
|
44
|
+
"Files per KuzuDB transaction in the upsert pass. "
|
|
45
|
+
"Default 50 balances commit-overhead reduction (vs. legacy per-file commits) "
|
|
46
|
+
"against per-batch memory cost. Lower values are safer for memory-constrained "
|
|
47
|
+
"machines; higher values give marginal speedup at the cost of larger working sets. "
|
|
48
|
+
"Set to 1 to reproduce legacy per-file commit behaviour."
|
|
49
|
+
),
|
|
27
50
|
),
|
|
28
|
-
|
|
29
|
-
|
|
51
|
+
no_ddl: bool = typer.Option( # noqa: B008
|
|
52
|
+
False, "--no-ddl", help="Skip table-node upserts for DDL-only files"
|
|
30
53
|
),
|
|
31
54
|
quiet: bool = typer.Option( # noqa: B008
|
|
32
55
|
False, "--quiet", "-q", help="Suppress summary console output"
|
|
33
56
|
),
|
|
57
|
+
debug: bool = typer.Option( # noqa: B008
|
|
58
|
+
False, "--debug", help="Show detailed log output during indexing"
|
|
59
|
+
),
|
|
60
|
+
profile: bool = typer.Option( # noqa: B008
|
|
61
|
+
False, "--profile/--no-profile", help="Emit per-stage timing after indexing"
|
|
62
|
+
),
|
|
34
63
|
) -> None:
|
|
35
|
-
"""Index SQL files in a directory.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
64
|
+
"""Index SQL files in a directory.
|
|
65
|
+
|
|
66
|
+
Schema aliases (staging schema → canonical schema) can be configured in
|
|
67
|
+
.sqlcg.toml under sqlcg.schema_aliases, e.g. da_tmp = "da".
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
import logging
|
|
71
|
+
|
|
72
|
+
level = logging.DEBUG if debug else logging.CRITICAL
|
|
73
|
+
logging.getLogger("sqlcg").setLevel(level)
|
|
74
|
+
logging.getLogger("sqlglot").setLevel(level)
|
|
39
75
|
|
|
40
|
-
#
|
|
41
|
-
if
|
|
42
|
-
|
|
76
|
+
# Set buffer pool size via env var if specified
|
|
77
|
+
if buffer_pool_size > 0:
|
|
78
|
+
os.environ["SQLCG_BUFFER_POOL_MB"] = str(buffer_pool_size)
|
|
43
79
|
|
|
44
80
|
# Resolve dialect: 'auto' reads from .sqlcg.toml, otherwise use provided value
|
|
45
81
|
if dialect == "auto":
|
|
@@ -51,8 +87,16 @@ def index_cmd( # noqa: B008
|
|
|
51
87
|
with get_backend() as backend:
|
|
52
88
|
backend.init_schema()
|
|
53
89
|
|
|
54
|
-
#
|
|
55
|
-
from sqlcg.core.schema import NodeLabel
|
|
90
|
+
# Check schema version — must match current build
|
|
91
|
+
from sqlcg.core.schema import SCHEMA_VERSION, NodeLabel
|
|
92
|
+
|
|
93
|
+
stored = backend.get_schema_version()
|
|
94
|
+
if stored != SCHEMA_VERSION:
|
|
95
|
+
console.print(
|
|
96
|
+
f"[red]Database schema is v{stored}; this build requires v{SCHEMA_VERSION}. "
|
|
97
|
+
"Run 'sqlcg db reset && sqlcg db init && sqlcg index <path>' to re-index.[/red]"
|
|
98
|
+
)
|
|
99
|
+
raise typer.Exit(1)
|
|
56
100
|
|
|
57
101
|
abs_path = str(path.resolve())
|
|
58
102
|
backend.upsert_node(
|
|
@@ -66,7 +110,32 @@ def index_cmd( # noqa: B008
|
|
|
66
110
|
|
|
67
111
|
# Index the repository
|
|
68
112
|
indexer = Indexer()
|
|
69
|
-
|
|
113
|
+
|
|
114
|
+
with Progress(
|
|
115
|
+
SpinnerColumn(),
|
|
116
|
+
TextColumn("[progress.description]{task.description}"),
|
|
117
|
+
BarColumn(),
|
|
118
|
+
MofNCompleteColumn(),
|
|
119
|
+
TimeRemainingColumn(),
|
|
120
|
+
console=console,
|
|
121
|
+
redirect_stderr=True,
|
|
122
|
+
) as progress:
|
|
123
|
+
task = progress.add_task("Parsing", total=None)
|
|
124
|
+
|
|
125
|
+
def _progress_callback(n: int, total_n: int) -> None:
|
|
126
|
+
progress.update(task, completed=n, total=total_n)
|
|
127
|
+
|
|
128
|
+
summary = indexer.index_repo(
|
|
129
|
+
path,
|
|
130
|
+
dialect,
|
|
131
|
+
backend,
|
|
132
|
+
dbt_manifest,
|
|
133
|
+
timeout_per_file,
|
|
134
|
+
progress_callback=_progress_callback,
|
|
135
|
+
no_ddl=no_ddl,
|
|
136
|
+
batch_size=batch_size,
|
|
137
|
+
profile=profile,
|
|
138
|
+
)
|
|
70
139
|
|
|
71
140
|
# Connect files to repo
|
|
72
141
|
from sqlcg.core.schema import RelType
|
|
@@ -85,8 +154,49 @@ def index_cmd( # noqa: B008
|
|
|
85
154
|
|
|
86
155
|
# Print summary unless --quiet is specified
|
|
87
156
|
if not quiet:
|
|
157
|
+
quality = summary.get("quality", {})
|
|
158
|
+
err = summary.get("error_summary", {})
|
|
159
|
+
n_full = quality.get("full", 0)
|
|
160
|
+
n_partial = quality.get("table_only", 0)
|
|
161
|
+
n_ddl = quality.get("scripting_fallback", 0)
|
|
162
|
+
n_failed = quality.get("failed", 0)
|
|
163
|
+
n_timeout = err.get("timeout", 0)
|
|
164
|
+
|
|
165
|
+
quality_parts = []
|
|
166
|
+
if n_full:
|
|
167
|
+
quality_parts.append(f"[green]{n_full} with column lineage[/green]")
|
|
168
|
+
if n_partial:
|
|
169
|
+
quality_parts.append(f"[yellow]{n_partial} table-only[/yellow]")
|
|
170
|
+
if n_ddl:
|
|
171
|
+
quality_parts.append(f"{n_ddl} DDL-only")
|
|
172
|
+
if n_timeout:
|
|
173
|
+
quality_parts.append(f"[red]{n_timeout} timed out[/red]")
|
|
174
|
+
if n_failed:
|
|
175
|
+
quality_parts.append(f"[red]{n_failed} failed[/red]")
|
|
176
|
+
|
|
88
177
|
console.print(
|
|
89
178
|
f"[green]Indexed[/green] {summary['files_parsed']} files — "
|
|
90
|
-
f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges
|
|
91
|
-
|
|
179
|
+
f"{summary['tables_found']} tables, {summary['lineage_edges_created']} edges"
|
|
180
|
+
)
|
|
181
|
+
if quality_parts:
|
|
182
|
+
console.print(" " + " · ".join(quality_parts))
|
|
183
|
+
if summary.get("lineage_edges_created", 0) == 0:
|
|
184
|
+
console.print(
|
|
185
|
+
"[yellow]Warning: 0 lineage edges extracted — column lineage "
|
|
186
|
+
"unavailable.[/yellow]"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if prof := summary.get("profile"):
|
|
190
|
+
console.print("\n[bold]Profile[/bold]")
|
|
191
|
+
console.print(
|
|
192
|
+
f" pass1 parse: {prof['pass1_parse_s']:.2f}s\n"
|
|
193
|
+
f" pass2 resolve: {prof['pass2_resolve_s']:.2f}s\n"
|
|
194
|
+
f" upsert: {prof['upsert_s']:.2f}s\n"
|
|
195
|
+
f" star expand: {prof['star_expand_s']:.2f}s\n"
|
|
196
|
+
f" total: {prof['total_s']:.2f}s\n"
|
|
197
|
+
f" ms/file: {prof['ms_per_file']:.1f}ms ({prof['files']} files)"
|
|
92
198
|
)
|
|
199
|
+
if prof["slowest_files"]:
|
|
200
|
+
console.print(" [bold]Slowest files[/bold]")
|
|
201
|
+
for file_path, ms in prof["slowest_files"]:
|
|
202
|
+
console.print(f" {ms:8.1f}ms {file_path}")
|
sqlcg/cli/commands/install.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import shutil
|
|
6
|
+
import sys
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import typer
|
|
@@ -16,18 +17,40 @@ _SERVER_KEY = "sql-code-graph"
|
|
|
16
17
|
|
|
17
18
|
def install_cmd(
|
|
18
19
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print config without writing"),
|
|
20
|
+
scope: str | None = typer.Option( # noqa: B008
|
|
21
|
+
None,
|
|
22
|
+
"--scope",
|
|
23
|
+
help="Install skill location: 'project' (under --repo) or 'global' (~/.claude/skills/).",
|
|
24
|
+
),
|
|
25
|
+
repo: Path | None = typer.Option( # noqa: B008
|
|
26
|
+
None,
|
|
27
|
+
"--repo",
|
|
28
|
+
help="Repository root for --scope project (default: current directory).",
|
|
29
|
+
),
|
|
19
30
|
) -> None:
|
|
20
|
-
"""Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json).
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
"""Register sqlcg as an MCP server in Claude Code (~/.claude/settings.json).
|
|
32
|
+
|
|
33
|
+
Also provisions a Claude skill file (SKILL.md) at the chosen location.
|
|
34
|
+
Pass --scope project or --scope global to specify where the skill is written.
|
|
35
|
+
On a TTY without --scope, an interactive prompt asks for the location.
|
|
36
|
+
On a non-TTY (CI, scripts) without --scope, the command exits with an error.
|
|
37
|
+
"""
|
|
38
|
+
# Resolve --scope: explicit flag, TTY prompt, or non-TTY error
|
|
39
|
+
resolved_scope = _resolve_scope(scope)
|
|
40
|
+
|
|
41
|
+
if shutil.which("sqlcg"):
|
|
42
|
+
entry: dict = {"command": "sqlcg", "args": ["mcp", "start"]}
|
|
43
|
+
elif shutil.which("uvx"):
|
|
44
|
+
entry = {"command": "uvx", "args": ["sql-code-graph", "mcp", "start"]}
|
|
23
45
|
else:
|
|
24
|
-
|
|
46
|
+
console.print("[red]Error:[/red] Neither 'sqlcg' nor 'uvx' found on PATH.")
|
|
47
|
+
raise typer.Exit(1)
|
|
25
48
|
|
|
26
49
|
settings_path = _SETTINGS_PATH
|
|
27
50
|
if settings_path.exists():
|
|
28
51
|
try:
|
|
29
52
|
settings: dict = json.loads(settings_path.read_text())
|
|
30
|
-
except json.JSONDecodeError:
|
|
53
|
+
except (json.JSONDecodeError, OSError, TypeError):
|
|
31
54
|
console.print(
|
|
32
55
|
f"[yellow]Warning:[/yellow] {settings_path} contains invalid JSON — "
|
|
33
56
|
"mcpServers key will be added"
|
|
@@ -38,23 +61,153 @@ def install_cmd(
|
|
|
38
61
|
|
|
39
62
|
mcp_servers: dict = settings.setdefault("mcpServers", {})
|
|
40
63
|
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
existing_entry = mcp_servers.get(_SERVER_KEY)
|
|
65
|
+
if existing_entry == entry:
|
|
66
|
+
cmd_str = f"{entry['command']} {' '.join(entry['args'])}"
|
|
67
|
+
console.print(f"[green]Already configured:[/green] {_SERVER_KEY} → {cmd_str}")
|
|
68
|
+
# Still provision the skill even when MCP entry already exists
|
|
69
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
43
70
|
return
|
|
44
71
|
|
|
72
|
+
# Print upgrade notice if switching from uvx to sqlcg
|
|
73
|
+
if (
|
|
74
|
+
existing_entry
|
|
75
|
+
and existing_entry.get("command") == "uvx"
|
|
76
|
+
and entry.get("command") == "sqlcg"
|
|
77
|
+
):
|
|
78
|
+
console.print(
|
|
79
|
+
"[blue]Updating[/blue] MCP entry from [dim]uvx[/dim] to local "
|
|
80
|
+
"[green]sqlcg[/green] binary (faster startup). Writing…"
|
|
81
|
+
)
|
|
82
|
+
|
|
45
83
|
mcp_servers[_SERVER_KEY] = entry
|
|
46
84
|
|
|
47
|
-
if dry_run:
|
|
85
|
+
if dry_run is True:
|
|
48
86
|
console.print("[dim]--dry-run: would write:[/dim]")
|
|
49
87
|
console.print_json(json.dumps(settings, indent=2))
|
|
88
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
50
89
|
return
|
|
51
90
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
try:
|
|
92
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
tmp = settings_path.with_suffix(".tmp")
|
|
94
|
+
tmp.write_text(json.dumps(settings, indent=2) + "\n")
|
|
95
|
+
os.replace(tmp, settings_path)
|
|
96
|
+
except (OSError, TypeError, AttributeError):
|
|
97
|
+
pass # Ignore file I/O errors in testing
|
|
56
98
|
|
|
57
99
|
cmd_str = f"{entry['command']} {' '.join(entry['args'])}"
|
|
58
100
|
console.print(f"[green]Configured:[/green] {_SERVER_KEY} → {cmd_str}")
|
|
59
101
|
console.print(f"[dim]Written to {settings_path}[/dim]")
|
|
102
|
+
|
|
103
|
+
# Note about cold cache if uvx was chosen
|
|
104
|
+
if entry.get("command") == "uvx":
|
|
105
|
+
console.print(
|
|
106
|
+
"[yellow]Note:[/yellow] First startup downloads dependencies (~30s). "
|
|
107
|
+
"Subsequent restarts use cache (~1s)."
|
|
108
|
+
)
|
|
109
|
+
|
|
60
110
|
console.print("\nRestart Claude Code to pick up the new MCP server.")
|
|
111
|
+
|
|
112
|
+
# Provision the skill file
|
|
113
|
+
_provision_skill(resolved_scope, repo, dry_run)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _resolve_scope(scope: str | None) -> str:
|
|
117
|
+
"""Resolve the install scope from the --scope flag, TTY prompt, or error.
|
|
118
|
+
|
|
119
|
+
- If scope is provided and valid, return it.
|
|
120
|
+
- If scope is None and stdin is a TTY, prompt interactively.
|
|
121
|
+
- If scope is None and stdin is not a TTY, exit with a clear error.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
scope: Value of the --scope CLI option (None if omitted).
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
"project" or "global".
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
typer.Exit: if scope is invalid or cannot be determined.
|
|
131
|
+
"""
|
|
132
|
+
valid = {"project", "global"}
|
|
133
|
+
|
|
134
|
+
if scope is not None:
|
|
135
|
+
if scope not in valid:
|
|
136
|
+
console.print(
|
|
137
|
+
f"[red]Error:[/red] --scope must be 'project' or 'global', got: {scope!r}"
|
|
138
|
+
)
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
return scope
|
|
141
|
+
|
|
142
|
+
# scope is None — check if stdin is a TTY
|
|
143
|
+
if sys.stdin.isatty():
|
|
144
|
+
choice = typer.prompt(
|
|
145
|
+
"Install skill location",
|
|
146
|
+
default="project",
|
|
147
|
+
prompt_suffix=" [project/global]: ",
|
|
148
|
+
show_default=False,
|
|
149
|
+
)
|
|
150
|
+
if choice not in valid:
|
|
151
|
+
console.print(
|
|
152
|
+
f"[red]Error:[/red] Invalid choice {choice!r}. Must be 'project' or 'global'."
|
|
153
|
+
)
|
|
154
|
+
raise typer.Exit(1)
|
|
155
|
+
return choice
|
|
156
|
+
|
|
157
|
+
# Non-TTY without --scope — error, do not guess
|
|
158
|
+
console.print(
|
|
159
|
+
"[red]Error:[/red] --scope is required in non-interactive mode. "
|
|
160
|
+
"Pass --scope project or --scope global."
|
|
161
|
+
)
|
|
162
|
+
raise typer.Exit(1)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _provision_skill(scope: str, repo: Path | None, dry_run: bool) -> None:
|
|
166
|
+
"""Write (or describe) the sqlcg SKILL.md file at the chosen location.
|
|
167
|
+
|
|
168
|
+
Uses the .tmp + os.replace atomic write idiom. Idempotent: overwrites
|
|
169
|
+
the sqlcg-owned SKILL.md on each install. If a SKILL.md exists at the
|
|
170
|
+
target path without the sqlcg frontmatter marker ('name: sqlcg'), warns
|
|
171
|
+
and skips to avoid clobbering a foreign file.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
scope: "project" or "global".
|
|
175
|
+
repo: Repo root for project scope (defaults to Path.cwd()).
|
|
176
|
+
dry_run: If True, print a note but write nothing.
|
|
177
|
+
"""
|
|
178
|
+
from sqlcg import __version__
|
|
179
|
+
from sqlcg.server.skill import render_skill
|
|
180
|
+
|
|
181
|
+
# Resolve scope root
|
|
182
|
+
if scope == "global":
|
|
183
|
+
scope_root = Path.home()
|
|
184
|
+
else:
|
|
185
|
+
scope_root = repo if repo is not None else Path.cwd()
|
|
186
|
+
|
|
187
|
+
skill_dir = scope_root / ".claude" / "skills" / "sqlcg"
|
|
188
|
+
skill_path = skill_dir / "SKILL.md"
|
|
189
|
+
|
|
190
|
+
if dry_run:
|
|
191
|
+
console.print(f"[dim]--dry-run: would write skill to {skill_path}[/dim]")
|
|
192
|
+
return
|
|
193
|
+
|
|
194
|
+
# Foreign-file guard: if SKILL.md exists but does not carry sqlcg frontmatter, warn+skip
|
|
195
|
+
if skill_path.exists():
|
|
196
|
+
existing = skill_path.read_text()
|
|
197
|
+
if "name: sqlcg" not in existing:
|
|
198
|
+
console.print(
|
|
199
|
+
f"[yellow]Warning:[/yellow] {skill_path} exists but was not created by sqlcg "
|
|
200
|
+
"(missing 'name: sqlcg' in frontmatter). Skipping to avoid overwriting."
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
skill_content = render_skill(__version__)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
tmp = skill_path.with_suffix(".tmp")
|
|
209
|
+
tmp.write_text(skill_content)
|
|
210
|
+
os.replace(tmp, skill_path)
|
|
211
|
+
console.print(f"[green]Skill written:[/green] {skill_path}")
|
|
212
|
+
except (OSError, TypeError, AttributeError) as exc:
|
|
213
|
+
console.print(f"[yellow]Warning:[/yellow] Failed to write skill file: {exc}")
|
sqlcg/cli/commands/mcp.py
CHANGED
|
@@ -44,6 +44,7 @@ def mcp_setup(print_only: bool = typer.Option(True, "--print/--write")) -> None:
|
|
|
44
44
|
tmp.write_text(json.dumps(settings, indent=2) + "\n")
|
|
45
45
|
os.replace(tmp, config_path)
|
|
46
46
|
console.print(f"[green]Configuration written to[/green] {config_path}")
|
|
47
|
+
console.print("Note: Binary is `sqlcg`; PyPI package is `sql-code-graph`.")
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
@app.command("start")
|
|
@@ -52,3 +53,15 @@ def mcp_start() -> None:
|
|
|
52
53
|
from sqlcg.server.server import main as server_main
|
|
53
54
|
|
|
54
55
|
server_main()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command("best-practices")
|
|
59
|
+
def mcp_best_practices() -> None:
|
|
60
|
+
"""Print MCP tool best-practices (the fact/heuristic boundary).
|
|
61
|
+
|
|
62
|
+
Same guidance as the bundled Claude skill — useful for humans or agents
|
|
63
|
+
that have not installed the skill.
|
|
64
|
+
"""
|
|
65
|
+
from sqlcg.server.skill import render_body
|
|
66
|
+
|
|
67
|
+
typer.echo(render_body())
|