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.
Files changed (45) hide show
  1. sql_code_graph-1.0.0.dist-info/METADATA +295 -0
  2. sql_code_graph-1.0.0.dist-info/RECORD +63 -0
  3. sqlcg/__init__.py +1 -1
  4. sqlcg/cli/commands/analyze.py +24 -0
  5. sqlcg/cli/commands/db.py +86 -5
  6. sqlcg/cli/commands/gain.py +74 -14
  7. sqlcg/cli/commands/git.py +71 -40
  8. sqlcg/cli/commands/index.py +127 -17
  9. sqlcg/cli/commands/install.py +165 -12
  10. sqlcg/cli/commands/mcp.py +13 -0
  11. sqlcg/cli/commands/reindex.py +170 -0
  12. sqlcg/cli/commands/uninstall.py +268 -0
  13. sqlcg/cli/commands/watch.py +14 -1
  14. sqlcg/cli/main.py +33 -2
  15. sqlcg/core/config.py +185 -2
  16. sqlcg/core/graph_db.py +65 -0
  17. sqlcg/core/kuzu_backend.py +199 -26
  18. sqlcg/core/neo4j_backend.py +38 -0
  19. sqlcg/core/queries.cypher +114 -0
  20. sqlcg/core/queries.py +44 -82
  21. sqlcg/core/schema.cypher +15 -3
  22. sqlcg/core/schema.py +2 -1
  23. sqlcg/indexer/error_classify.py +140 -0
  24. sqlcg/indexer/git_delta.py +121 -0
  25. sqlcg/indexer/indexer.py +957 -112
  26. sqlcg/indexer/pool.py +446 -0
  27. sqlcg/indexer/walker.py +1 -3
  28. sqlcg/indexer/watcher.py +68 -18
  29. sqlcg/lineage/aggregator.py +58 -2
  30. sqlcg/lineage/schema_resolver.py +26 -14
  31. sqlcg/parsers/ansi_parser.py +210 -24
  32. sqlcg/parsers/base.py +620 -54
  33. sqlcg/parsers/bigquery_parser.py +9 -4
  34. sqlcg/parsers/postgres_parser.py +7 -2
  35. sqlcg/parsers/registry.py +7 -2
  36. sqlcg/parsers/snowflake_parser.py +173 -10
  37. sqlcg/parsers/tsql_parser.py +7 -2
  38. sqlcg/server/models.py +338 -1
  39. sqlcg/server/noise_filter.py +167 -0
  40. sqlcg/server/skill.py +256 -0
  41. sqlcg/server/tools.py +1036 -147
  42. sql_code_graph-0.2.1.dist-info/METADATA +0 -171
  43. sql_code_graph-0.2.1.dist-info/RECORD +0 -55
  44. {sql_code_graph-0.2.1.dist-info → sql_code_graph-1.0.0.dist-info}/WHEEL +0 -0
  45. {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 graph resync after branch switches.
22
- Idempotent: running multiple times produces one hook entry.
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
- hook_path = hooks_dir / "post-checkout"
37
- hook_sentinel = "# sqlcg post-checkout hook"
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)
@@ -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
- 30, "--timeout-per-file", help="Timeout per file in seconds"
32
+ 5, "--timeout-per-file", help="Timeout per file in seconds"
24
33
  ),
25
- no_ddl: bool = typer.Option( # noqa: B008
26
- False, "--no-ddl", help="Skip DDL statements (not yet fully implemented)"
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
- schema_from_info_schema: str | None = typer.Option( # noqa: B008
29
- None, "--schema-from-info-schema", hidden=True, help="(Not yet implemented)"
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
- if schema_from_info_schema:
37
- console.print("[red]--schema-from-info-schema is not yet implemented (v2)[/red]")
38
- raise typer.Exit(1)
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
- # TODO: wire no_ddl through to the indexer once it supports the parameter
41
- if no_ddl:
42
- console.print("[yellow]Note: --no-ddl is not yet fully implemented[/yellow]")
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
- # Create Repo node for this repository
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
- summary = indexer.index_repo(path, dialect, backend, dbt_manifest, timeout_per_file)
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
- f"{summary['parse_errors']} errors"
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}")
@@ -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
- if shutil.which("uvx"):
22
- entry: dict = {"command": "uvx", "args": ["sql-code-graph", "mcp", "start"]}
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
- entry = {"command": "sqlcg", "args": ["mcp", "start"]}
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
- if mcp_servers.get(_SERVER_KEY) == entry:
42
- console.print(f"[green]Already configured:[/green] {_SERVER_KEY} → {settings_path}")
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
- settings_path.parent.mkdir(parents=True, exist_ok=True)
53
- tmp = settings_path.with_suffix(".tmp")
54
- tmp.write_text(json.dumps(settings, indent=2) + "\n")
55
- os.replace(tmp, settings_path)
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())