graphscout 0.2.0__tar.gz

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,22 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - name: Install
20
+ run: pip install -e ".[dev,mcp,watch]"
21
+ - name: Test
22
+ run: pytest -v
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duc Nguyen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,144 @@
1
+ Metadata-Version: 2.4
2
+ Name: graphscout
3
+ Version: 0.2.0
4
+ Summary: Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files. CLI + MCP server, wires into Claude Code, Codex, Gemini CLI, and Cursor. Auto-sync watch mode.
5
+ Project-URL: Homepage, https://github.com/nguyenminhduc9988/graphscout
6
+ Project-URL: Repository, https://github.com/nguyenminhduc9988/graphscout
7
+ Project-URL: Issues, https://github.com/nguyenminhduc9988/graphscout/issues
8
+ Author: Duc Nguyen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,claude-code,code-graph,codebase-map,mcp,static-analysis,token-efficiency,tree-sitter
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: graphifyy>=0.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == 'dev'
26
+ Requires-Dist: pytest>=8; extra == 'dev'
27
+ Requires-Dist: twine; extra == 'dev'
28
+ Provides-Extra: mcp
29
+ Requires-Dist: mcp>=1.0; extra == 'mcp'
30
+ Provides-Extra: watch
31
+ Requires-Dist: watchdog>=4.0; extra == 'watch'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # graphscout
35
+
36
+ **Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files.**
37
+
38
+ Agents burn most of their tokens reading source files to answer structural questions — "where is this defined?", "who calls this?", "what does this file import?". `graphscout` answers those questions from a cached tree-sitter AST graph in milliseconds, so the agent reads only the exact line ranges it needs.
39
+
40
+ ```
41
+ $ graphscout sym cli_fallback
42
+ CLIFallback [class] agent/cli_fallback.py:24-210
43
+ run_cascade [function] agent/cli_fallback.py:96-158
44
+
45
+ $ graphscout callers run_cascade
46
+ handle_turn [function] agent/loop.py:311-360
47
+ retry_turn [function] agent/loop.py:402-431
48
+ ```
49
+
50
+ One `build` per repo; after that, every query auto-refreshes only the files that changed since the last call (mtime-based). No forced background process, no database, no API keys — a JSON cache under `~/.cache/graphscout`. Want it always fresh with zero per-query overhead instead? Run `graphscout watch` — see [Auto-sync](#auto-sync-optional).
51
+
52
+ > Formerly published as `codegraph-kit` (repo `codegraph`) — renamed to avoid confusion with the unrelated, much larger [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) project. Same tool, same cache format (`$CODEGRAPH_CACHE` still works as a fallback env var).
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install graphscout # CLI
58
+ pip install "graphscout[mcp]" # CLI + MCP server
59
+ pip install "graphscout[watch]" # CLI + instant filesystem-event auto-sync
60
+ ```
61
+
62
+ Python ≥ 3.10. Parsing is done by [graphify](https://pypi.org/project/graphifyy/) (tree-sitter), which extracts real defs/calls/imports for Python, JavaScript, TypeScript/TSX, Java, Groovy, C, C++, Ruby, C#, Kotlin, Scala, PHP, Lua, and Swift, and walks 40+ other extensions (Go, Rust, Vue, Svelte, Astro, Dart, Elixir, Terraform, and more) for outline/import-level structure.
63
+
64
+ ## Commands
65
+
66
+ | Command | What it answers |
67
+ |---|---|
68
+ | `graphscout build [dir]` | full graph build (run once per repo) |
69
+ | `graphscout map [dir]` | repo overview: size, per-directory breakdown, top hub symbols |
70
+ | `graphscout file <path>` | outline of one file: definitions + line ranges |
71
+ | `graphscout sym <name>` | where is this symbol defined? |
72
+ | `graphscout callers <name>` | who calls it? |
73
+ | `graphscout callees <name>` | what does it call? |
74
+ | `graphscout deps <path>` | what does this file import? |
75
+ | `graphscout ensure [dir]` | incremental refresh (queries do this automatically) |
76
+ | `graphscout watch [dir]` | block, keeping the graph in sync as files change |
77
+ | `graphscout touch <path>` | re-extract one file (for editor/agent hooks) |
78
+ | `graphscout agent` | print an instruction snippet for your agent's context file |
79
+ | `graphscout install [agent...]` | wire the MCP server into detected agents |
80
+ | `graphscout uninstall [agent...]` | remove it again |
81
+ | `graphscout mcp` | run as an MCP server (stdio) |
82
+
83
+ ## Integrate with any agent
84
+
85
+ `graphscout` is plain CLI-over-stdout, so **any agent that can run shell commands can use it** — Claude Code, Codex CLI, Cursor, Aider, OpenHands, Goose, custom agents. Two steps:
86
+
87
+ **1. Tell the agent the graph exists.** Append the ready-made snippet to your agent's context file:
88
+
89
+ ```bash
90
+ graphscout agent >> AGENTS.md # or CLAUDE.md, .cursorrules, .github/copilot-instructions.md
91
+ ```
92
+
93
+ **2. (Optional) Keep the graph fresh on every edit.** For Claude Code, install the bundled PostToolUse hook so each `Edit`/`Write` re-extracts just that file:
94
+
95
+ ```bash
96
+ cp integrations/claude-code/graphscout-touch.sh ~/.claude/hooks/
97
+ chmod +x ~/.claude/hooks/graphscout-touch.sh
98
+ # then merge integrations/claude-code/settings-snippet.json into ~/.claude/settings.json
99
+ ```
100
+
101
+ Even without a hook, queries stay correct: every query runs an mtime check first and re-extracts anything stale.
102
+
103
+ ### MCP, wired automatically
104
+
105
+ ```bash
106
+ pip install "graphscout[mcp]"
107
+ graphscout install # auto-detects and wires every agent found on PATH
108
+ graphscout install cursor # or target specific agents: claude-code, codex, gemini, cursor
109
+ graphscout uninstall # reverse it
110
+ ```
111
+
112
+ `install` shells out to each agent's own `mcp add` command where one exists (Claude Code, Codex CLI, Gemini CLI — verified against their real CLIs, not guessed), and edits `~/.cursor/mcp.json` directly for Cursor, which has no such subcommand. It's idempotent — safe to re-run.
113
+
114
+ Tools exposed: `build_graph`, `graph_map`, `file_outline`, `find_symbol`, `callers`, `callees`, `file_deps` — same output as the CLI.
115
+
116
+ ### Auto-sync (optional)
117
+
118
+ ```bash
119
+ graphscout watch # blocks, keeps the graph in sync as you/your agent edit files
120
+ ```
121
+
122
+ Uses [watchdog](https://pypi.org/project/watchdog/) for instant, low-CPU filesystem events when installed (`pip install "graphscout[watch]"`); falls back to a ~1.5s mtime poll otherwise. This is the always-fresh alternative to the per-edit `touch` hook above — run one or the other, not both. Skip both and every query still self-heals via its own mtime check; `watch` just removes that per-query overhead.
123
+
124
+ ## Why not just let the agent read files?
125
+
126
+ Reading a 1,500-line file to find one function costs ~15k tokens; `graphscout file` returns the outline in ~200 tokens, and the agent then reads only the 40-line range it needs. On large repos the difference compounds — structural questions (symbol lookup, call tracing, import mapping) stop costing file-reads entirely.
127
+
128
+ Honest limitations, printed in the output when they apply:
129
+
130
+ - **Dynamic dispatch isn't captured** — call edges come from static AST analysis; `getattr`-style calls need grep.
131
+ - **Unsupported/exotic languages** fall back to "read it directly".
132
+ - Caps: 5,000 files per repo, 1 MB per file (warned, not silent).
133
+
134
+ ## How it works
135
+
136
+ 1. `build` walks the repo (skipping `node_modules`, `venv`, `dist`, …), runs tree-sitter extraction via graphify, normalizes all paths root-relative, and writes `graph.json` + an mtime index to `~/.cache/graphscout/<repo-hash>/`.
137
+ 2. Every query calls `ensure` first: files whose mtime changed are re-extracted and spliced into the graph; deleted files are dropped. Typical refresh is a handful of files, so queries stay fast. `watch` does the same refresh on a timer/event loop instead of per-query.
138
+ 3. Output is deliberately plain text with `file:line` locations — clickable in most agent UIs and trivially parseable.
139
+
140
+ Set `GRAPHSCOUT_CACHE` to relocate the cache (useful in CI and sandboxes).
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,111 @@
1
+ # graphscout
2
+
3
+ **Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files.**
4
+
5
+ Agents burn most of their tokens reading source files to answer structural questions — "where is this defined?", "who calls this?", "what does this file import?". `graphscout` answers those questions from a cached tree-sitter AST graph in milliseconds, so the agent reads only the exact line ranges it needs.
6
+
7
+ ```
8
+ $ graphscout sym cli_fallback
9
+ CLIFallback [class] agent/cli_fallback.py:24-210
10
+ run_cascade [function] agent/cli_fallback.py:96-158
11
+
12
+ $ graphscout callers run_cascade
13
+ handle_turn [function] agent/loop.py:311-360
14
+ retry_turn [function] agent/loop.py:402-431
15
+ ```
16
+
17
+ One `build` per repo; after that, every query auto-refreshes only the files that changed since the last call (mtime-based). No forced background process, no database, no API keys — a JSON cache under `~/.cache/graphscout`. Want it always fresh with zero per-query overhead instead? Run `graphscout watch` — see [Auto-sync](#auto-sync-optional).
18
+
19
+ > Formerly published as `codegraph-kit` (repo `codegraph`) — renamed to avoid confusion with the unrelated, much larger [colbymchenry/codegraph](https://github.com/colbymchenry/codegraph) project. Same tool, same cache format (`$CODEGRAPH_CACHE` still works as a fallback env var).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pip install graphscout # CLI
25
+ pip install "graphscout[mcp]" # CLI + MCP server
26
+ pip install "graphscout[watch]" # CLI + instant filesystem-event auto-sync
27
+ ```
28
+
29
+ Python ≥ 3.10. Parsing is done by [graphify](https://pypi.org/project/graphifyy/) (tree-sitter), which extracts real defs/calls/imports for Python, JavaScript, TypeScript/TSX, Java, Groovy, C, C++, Ruby, C#, Kotlin, Scala, PHP, Lua, and Swift, and walks 40+ other extensions (Go, Rust, Vue, Svelte, Astro, Dart, Elixir, Terraform, and more) for outline/import-level structure.
30
+
31
+ ## Commands
32
+
33
+ | Command | What it answers |
34
+ |---|---|
35
+ | `graphscout build [dir]` | full graph build (run once per repo) |
36
+ | `graphscout map [dir]` | repo overview: size, per-directory breakdown, top hub symbols |
37
+ | `graphscout file <path>` | outline of one file: definitions + line ranges |
38
+ | `graphscout sym <name>` | where is this symbol defined? |
39
+ | `graphscout callers <name>` | who calls it? |
40
+ | `graphscout callees <name>` | what does it call? |
41
+ | `graphscout deps <path>` | what does this file import? |
42
+ | `graphscout ensure [dir]` | incremental refresh (queries do this automatically) |
43
+ | `graphscout watch [dir]` | block, keeping the graph in sync as files change |
44
+ | `graphscout touch <path>` | re-extract one file (for editor/agent hooks) |
45
+ | `graphscout agent` | print an instruction snippet for your agent's context file |
46
+ | `graphscout install [agent...]` | wire the MCP server into detected agents |
47
+ | `graphscout uninstall [agent...]` | remove it again |
48
+ | `graphscout mcp` | run as an MCP server (stdio) |
49
+
50
+ ## Integrate with any agent
51
+
52
+ `graphscout` is plain CLI-over-stdout, so **any agent that can run shell commands can use it** — Claude Code, Codex CLI, Cursor, Aider, OpenHands, Goose, custom agents. Two steps:
53
+
54
+ **1. Tell the agent the graph exists.** Append the ready-made snippet to your agent's context file:
55
+
56
+ ```bash
57
+ graphscout agent >> AGENTS.md # or CLAUDE.md, .cursorrules, .github/copilot-instructions.md
58
+ ```
59
+
60
+ **2. (Optional) Keep the graph fresh on every edit.** For Claude Code, install the bundled PostToolUse hook so each `Edit`/`Write` re-extracts just that file:
61
+
62
+ ```bash
63
+ cp integrations/claude-code/graphscout-touch.sh ~/.claude/hooks/
64
+ chmod +x ~/.claude/hooks/graphscout-touch.sh
65
+ # then merge integrations/claude-code/settings-snippet.json into ~/.claude/settings.json
66
+ ```
67
+
68
+ Even without a hook, queries stay correct: every query runs an mtime check first and re-extracts anything stale.
69
+
70
+ ### MCP, wired automatically
71
+
72
+ ```bash
73
+ pip install "graphscout[mcp]"
74
+ graphscout install # auto-detects and wires every agent found on PATH
75
+ graphscout install cursor # or target specific agents: claude-code, codex, gemini, cursor
76
+ graphscout uninstall # reverse it
77
+ ```
78
+
79
+ `install` shells out to each agent's own `mcp add` command where one exists (Claude Code, Codex CLI, Gemini CLI — verified against their real CLIs, not guessed), and edits `~/.cursor/mcp.json` directly for Cursor, which has no such subcommand. It's idempotent — safe to re-run.
80
+
81
+ Tools exposed: `build_graph`, `graph_map`, `file_outline`, `find_symbol`, `callers`, `callees`, `file_deps` — same output as the CLI.
82
+
83
+ ### Auto-sync (optional)
84
+
85
+ ```bash
86
+ graphscout watch # blocks, keeps the graph in sync as you/your agent edit files
87
+ ```
88
+
89
+ Uses [watchdog](https://pypi.org/project/watchdog/) for instant, low-CPU filesystem events when installed (`pip install "graphscout[watch]"`); falls back to a ~1.5s mtime poll otherwise. This is the always-fresh alternative to the per-edit `touch` hook above — run one or the other, not both. Skip both and every query still self-heals via its own mtime check; `watch` just removes that per-query overhead.
90
+
91
+ ## Why not just let the agent read files?
92
+
93
+ Reading a 1,500-line file to find one function costs ~15k tokens; `graphscout file` returns the outline in ~200 tokens, and the agent then reads only the 40-line range it needs. On large repos the difference compounds — structural questions (symbol lookup, call tracing, import mapping) stop costing file-reads entirely.
94
+
95
+ Honest limitations, printed in the output when they apply:
96
+
97
+ - **Dynamic dispatch isn't captured** — call edges come from static AST analysis; `getattr`-style calls need grep.
98
+ - **Unsupported/exotic languages** fall back to "read it directly".
99
+ - Caps: 5,000 files per repo, 1 MB per file (warned, not silent).
100
+
101
+ ## How it works
102
+
103
+ 1. `build` walks the repo (skipping `node_modules`, `venv`, `dist`, …), runs tree-sitter extraction via graphify, normalizes all paths root-relative, and writes `graph.json` + an mtime index to `~/.cache/graphscout/<repo-hash>/`.
104
+ 2. Every query calls `ensure` first: files whose mtime changed are re-extracted and spliced into the graph; deleted files are dropped. Typical refresh is a handful of files, so queries stay fast. `watch` does the same refresh on a timer/event loop instead of per-query.
105
+ 3. Output is deliberately plain text with `file:line` locations — clickable in most agent UIs and trivially parseable.
106
+
107
+ Set `GRAPHSCOUT_CACHE` to relocate the cache (useful in CI and sandboxes).
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # Claude Code PostToolUse hook (Edit|Write, async): keep code graphs fresh —
3
+ # re-extract the edited file into its repo's graphscout cache.
4
+ # No-op when the repo has no graph yet, so it's safe to install globally.
5
+ set -uo pipefail
6
+ FP=$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')
7
+ [[ -z "$FP" || ! -f "$FP" ]] && exit 0
8
+ case "${FP##*.}" in
9
+ py|js|jsx|mjs|ts|tsx|vue|svelte|astro|go|rs|zig|java|groovy|kt|kts|scala|\
10
+ c|h|cpp|cc|cxx|hpp|cs|rb|php|swift|m|mm|lua|dart|ex|exs|jl|r|v|sv|sh|bash|ps1|tf) ;;
11
+ *) exit 0 ;;
12
+ esac
13
+ exec graphscout touch "$FP" 2>/dev/null
@@ -0,0 +1,17 @@
1
+ {
2
+ "hooks": {
3
+ "PostToolUse": [
4
+ {
5
+ "matcher": "Edit|Write",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "~/.claude/hooks/graphscout-touch.sh",
10
+ "timeout": 60,
11
+ "async": true
12
+ }
13
+ ]
14
+ }
15
+ ]
16
+ }
17
+ }
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "graphscout"
7
+ version = "0.2.0"
8
+ description = "Cached, incremental code-graph maps so AI agents query code structure instead of reading whole files. CLI + MCP server, wires into Claude Code, Codex, Gemini CLI, and Cursor. Auto-sync watch mode."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Duc Nguyen" }]
13
+ keywords = ["ai-agents", "code-graph", "tree-sitter", "claude-code", "mcp", "token-efficiency", "codebase-map", "static-analysis"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: Software Development :: Quality Assurance",
25
+ ]
26
+ dependencies = [
27
+ "graphifyy>=0.9",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ mcp = ["mcp>=1.0"]
32
+ watch = ["watchdog>=4.0"]
33
+ dev = ["pytest>=8", "build", "twine"]
34
+
35
+ [project.scripts]
36
+ graphscout = "graphscout.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/nguyenminhduc9988/graphscout"
40
+ Repository = "https://github.com/nguyenminhduc9988/graphscout"
41
+ Issues = "https://github.com/nguyenminhduc9988/graphscout/issues"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/graphscout"]
@@ -0,0 +1,4 @@
1
+ """graphscout — cached, incremental code-graph maps so AI agents query structure
2
+ instead of reading whole files."""
3
+
4
+ __version__ = "0.2.0"
@@ -0,0 +1,134 @@
1
+ """Wire the graphscout MCP server into agent CLIs, and remove it again.
2
+
3
+ Each agent is either:
4
+ - "cli": has its own `<tool> mcp add/remove <name> -- <command> [args]`
5
+ subcommand, which we shell out to (most robust — we never guess at that
6
+ tool's own config file format or schema).
7
+ - "json": no MCP-management subcommand, so we edit its documented MCP
8
+ config file directly (mcpServers.<name> = {command, args}).
9
+
10
+ Only agents whose registration mechanism was verified against the real CLI
11
+ (or a stable, documented config schema) are listed here — silently guessing
12
+ at an unverified format would ship a broken integration, which is worse than
13
+ not shipping one.
14
+ """
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ SERVER_NAME = "graphscout"
21
+ SERVER_CMD = ["graphscout", "mcp"]
22
+
23
+ AGENTS = {
24
+ "claude-code": {
25
+ "kind": "cli",
26
+ "binary": "claude",
27
+ "add": lambda b: [b, "mcp", "add", SERVER_NAME, "-s", "user", "--", *SERVER_CMD],
28
+ "remove": lambda b: [b, "mcp", "remove", SERVER_NAME, "-s", "user"],
29
+ },
30
+ "codex": {
31
+ "kind": "cli",
32
+ "binary": "codex",
33
+ "add": lambda b: [b, "mcp", "add", SERVER_NAME, "--", *SERVER_CMD],
34
+ "remove": lambda b: [b, "mcp", "remove", SERVER_NAME],
35
+ },
36
+ "gemini": {
37
+ "kind": "cli",
38
+ "binary": "gemini",
39
+ "add": lambda b: [b, "mcp", "add", "-s", "user", SERVER_NAME, *SERVER_CMD],
40
+ "remove": lambda b: [b, "mcp", "remove", "-s", "user", SERVER_NAME],
41
+ },
42
+ "cursor": {
43
+ "kind": "json",
44
+ "path": Path.home() / ".cursor" / "mcp.json",
45
+ },
46
+ }
47
+
48
+
49
+ def detect() -> dict:
50
+ """Which configured agents are actually present on this machine."""
51
+ out = {}
52
+ for name, spec in AGENTS.items():
53
+ if spec["kind"] == "cli":
54
+ out[name] = shutil.which(spec["binary"]) is not None
55
+ else:
56
+ # A json-config agent counts as "present" if its config dir's
57
+ # parent exists (e.g. ~/.cursor) — the file itself may not yet.
58
+ out[name] = spec["path"].parent.exists()
59
+ return out
60
+
61
+
62
+ def _run(argv):
63
+ try:
64
+ r = subprocess.run(argv, capture_output=True, text=True, timeout=20)
65
+ return r.returncode == 0, (r.stdout + r.stderr).strip()
66
+ except (OSError, subprocess.SubprocessError) as e:
67
+ return False, str(e)
68
+
69
+
70
+ def _json_install(path: Path):
71
+ path.parent.mkdir(parents=True, exist_ok=True)
72
+ try:
73
+ cfg = json.loads(path.read_text()) if path.exists() else {}
74
+ except json.JSONDecodeError:
75
+ return f"skip {path} — not valid JSON, edit it by hand"
76
+ cfg.setdefault("mcpServers", {})[SERVER_NAME] = {"command": SERVER_CMD[0], "args": SERVER_CMD[1:]}
77
+ path.write_text(json.dumps(cfg, indent=2) + "\n")
78
+ return f"wired {path}"
79
+
80
+
81
+ def _json_uninstall(path: Path):
82
+ if not path.exists():
83
+ return f"skip {path} — not present"
84
+ try:
85
+ cfg = json.loads(path.read_text())
86
+ except json.JSONDecodeError:
87
+ return f"skip {path} — not valid JSON, edit it by hand"
88
+ removed = cfg.get("mcpServers", {}).pop(SERVER_NAME, None) is not None
89
+ path.write_text(json.dumps(cfg, indent=2) + "\n")
90
+ return f"removed from {path}" if removed else f"not present in {path}"
91
+
92
+
93
+ def install(names=None) -> list:
94
+ present = detect()
95
+ targets = names or [n for n, ok in present.items() if ok]
96
+ log = []
97
+ for name in targets:
98
+ spec = AGENTS.get(name)
99
+ if not spec:
100
+ log.append(f"unknown agent: {name}")
101
+ continue
102
+ if not present.get(name) and not names:
103
+ continue # auto mode: skip agents that aren't installed
104
+ if spec["kind"] == "cli":
105
+ b = shutil.which(spec["binary"])
106
+ if not b:
107
+ log.append(f"skip {name} — `{spec['binary']}` not on PATH")
108
+ continue
109
+ _run(spec["remove"](b)) # idempotent: clear any stale entry first
110
+ ok, msg = _run(spec["add"](b))
111
+ log.append(f"{'wired' if ok else 'FAILED'} {name}" + (f" — {msg}" if not ok else ""))
112
+ else:
113
+ log.append(_json_install(spec["path"]))
114
+ return log
115
+
116
+
117
+ def uninstall(names=None) -> list:
118
+ targets = names or list(AGENTS)
119
+ log = []
120
+ for name in targets:
121
+ spec = AGENTS.get(name)
122
+ if not spec:
123
+ log.append(f"unknown agent: {name}")
124
+ continue
125
+ if spec["kind"] == "cli":
126
+ b = shutil.which(spec["binary"])
127
+ if not b:
128
+ log.append(f"skip {name} — `{spec['binary']}` not on PATH")
129
+ continue
130
+ ok, msg = _run(spec["remove"](b))
131
+ log.append(f"{'removed' if ok else 'not present'} {name}")
132
+ else:
133
+ log.append(_json_uninstall(spec["path"]))
134
+ return log
@@ -0,0 +1,125 @@
1
+ """graphscout — cached, incremental code-graph maps so agents query structure
2
+ instead of reading whole files. Backed by graphify (tree-sitter AST extraction).
3
+
4
+ graphscout build [dir] full graph build (registers repo root)
5
+ graphscout ensure [dir] incremental refresh (only changed files re-extracted)
6
+ graphscout watch [dir] block, keeping the graph in sync as files change
7
+ graphscout map [dir] compact overview: counts, per-dir breakdown, top hubs
8
+ graphscout file <path> outline of one file (defs + line ranges -> sliced reads)
9
+ graphscout sym <name> [dir] locate symbol by substring -> file + lines
10
+ graphscout callers <name> [dir] who calls it
11
+ graphscout callees <name> [dir] what it calls
12
+ graphscout deps <path> imports of a file
13
+ graphscout touch <path> re-extract one file into its repo's cache (hook use)
14
+ graphscout agent print an instruction snippet for AGENTS.md / CLAUDE.md
15
+ graphscout install [agent...] wire the MCP server into detected agents (claude-code, codex, gemini, cursor)
16
+ graphscout uninstall [agent...] remove it again
17
+ graphscout mcp run as an MCP server (requires `pip install graphscout[mcp]`)
18
+ graphscout --version print version
19
+
20
+ Queries auto-run `ensure` first, so the graph tracks the working tree.
21
+ """
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ from . import __version__, core, queries
26
+
27
+ AGENT_SNIPPET = """\
28
+ ## Code navigation — graph first, read only what you need
29
+
30
+ This repo has `graphscout` (cached tree-sitter code graphs). Before reading
31
+ source files, query the graph and then read ONLY the located line ranges:
32
+
33
+ - `graphscout map` — repo overview: size, per-directory breakdown, top hub symbols
34
+ - `graphscout file <path>` — outline of a file (definitions + line ranges)
35
+ - `graphscout sym <name>` — locate a symbol -> file:lines
36
+ - `graphscout callers <name>` / `graphscout callees <name>` — trace call edges
37
+ - `graphscout deps <path>` — what a file imports
38
+
39
+ Run `graphscout build` once per repo; afterwards every query refreshes changed
40
+ files automatically. Fall back to reading whole files only when the graph
41
+ can't answer (unsupported language, dynamic dispatch, subtle logic).
42
+ """
43
+
44
+
45
+ def main(argv=None):
46
+ args = sys.argv[1:] if argv is None else argv
47
+ if not args or args[0] in ("-h", "--help", "help"):
48
+ print(__doc__)
49
+ return 0
50
+ cmd = args[0]
51
+ tail = args[1:]
52
+
53
+ if cmd in ("--version", "-V", "version"):
54
+ print(f"graphscout {__version__}")
55
+ return 0
56
+ if cmd == "agent":
57
+ print(AGENT_SNIPPET)
58
+ return 0
59
+ if cmd == "mcp":
60
+ from .mcp_server import run # lazy: needs the [mcp] extra
61
+ run()
62
+ return 0
63
+ if cmd in ("install", "uninstall"):
64
+ from . import agents
65
+ fn = agents.install if cmd == "install" else agents.uninstall
66
+ names = tail or None
67
+ for line in fn(names):
68
+ print(line)
69
+ return 0
70
+
71
+ target = None
72
+ if cmd in ("build", "ensure", "watch", "map", "hubs"):
73
+ root = core.find_root(Path(tail[0]).resolve() if tail else Path.cwd())
74
+ elif cmd in ("file", "deps", "touch"):
75
+ if not tail:
76
+ print(f"usage: graphscout {cmd} <path>", file=sys.stderr)
77
+ return 2
78
+ target = Path(tail[0]).resolve()
79
+ root = core.find_root(target)
80
+ elif cmd in ("sym", "callers", "callees"):
81
+ if not tail:
82
+ print(f"usage: graphscout {cmd} <name> [dir]", file=sys.stderr)
83
+ return 2
84
+ root = core.find_root(Path(tail[1]).resolve() if len(tail) > 1 else Path.cwd())
85
+ else:
86
+ print(f"unknown command: {cmd}")
87
+ print(__doc__)
88
+ return 2
89
+
90
+ if cmd == "build":
91
+ g, idx, n = core.build(root)
92
+ print(f"built {root}: {n} files -> {len(g['nodes'])} nodes, {len(g['edges'])} edges")
93
+ return 0
94
+ if cmd == "touch":
95
+ core.touch(target, root)
96
+ return 0
97
+ if cmd == "watch":
98
+ print(f"[graphscout] watching {root} — Ctrl-C to stop", file=sys.stderr)
99
+ try:
100
+ for status in core.watch(root):
101
+ if status:
102
+ print(status, file=sys.stderr)
103
+ except KeyboardInterrupt:
104
+ pass
105
+ return 0
106
+
107
+ g = core.ensure(root)
108
+
109
+ if cmd == "ensure":
110
+ print(f"{root}: {len(g['nodes'])} nodes, {len(g['edges'])} edges (fresh)")
111
+ elif cmd in ("map", "hubs"):
112
+ print(queries.q_map(root, g))
113
+ elif cmd == "file":
114
+ print(queries.q_file(root, g, target))
115
+ elif cmd == "sym":
116
+ print(queries.q_sym(root, g, tail[0]))
117
+ elif cmd in ("callers", "callees"):
118
+ print(queries.q_calls(root, g, tail[0], cmd))
119
+ elif cmd == "deps":
120
+ print(queries.q_deps(root, g, target))
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ sys.exit(main())
@@ -0,0 +1,239 @@
1
+ """Graph building, caching, and incremental refresh.
2
+
3
+ Graphs are stored per-repo under the cache dir (default ~/.cache/graphscout,
4
+ override with $GRAPHSCOUT_CACHE). Extraction is delegated to graphify
5
+ (tree-sitter AST parsing); this layer adds root discovery, mtime-based
6
+ incremental rebuilds, and root-relative path normalization.
7
+ """
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ # Every extension graphify can walk into a real language extractor (defs, calls,
16
+ # imports) — not just files it can list. Kept as a static set rather than
17
+ # importing graphify.detect at module scope, since that's an internal API.
18
+ CODE_EXTS = {
19
+ ".py", ".js", ".jsx", ".mjs", ".ts", ".tsx", ".ejs", ".ets", ".vue", ".svelte", ".astro",
20
+ ".go", ".rs", ".zig", ".java", ".groovy", ".kt", ".kts", ".scala",
21
+ ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".cs", ".razor", ".cshtml",
22
+ ".rb", ".php", ".swift", ".m", ".mm", ".lua", ".luau", ".dart",
23
+ ".ex", ".exs", ".jl", ".r", ".v", ".sv", ".svh",
24
+ ".pas", ".pp", ".dpr", ".dpk", ".lpr", ".lpk", ".dfm", ".lfm",
25
+ ".sh", ".bash", ".ps1", ".hcl", ".tf", ".tfvars",
26
+ ".f", ".f90", ".f95", ".f03", ".f08",
27
+ }
28
+ SKIP_DIRS = {".git", "node_modules", "venv", ".venv", "__pycache__", "dist", "build",
29
+ ".next", "target", ".cache", "vendor", "site-packages", ".tox", "coverage"}
30
+ MAX_FILES = 5000
31
+ MAX_FILE_BYTES = 1_000_000
32
+
33
+
34
+ def cache_dir() -> Path:
35
+ env = os.environ.get("GRAPHSCOUT_CACHE") or os.environ.get("CODEGRAPH_CACHE")
36
+ return Path(env) if env else Path.home() / ".cache" / "graphscout"
37
+
38
+
39
+ def roots_file() -> Path:
40
+ return cache_dir() / "roots.json"
41
+
42
+
43
+ def repo_key(root: Path) -> Path:
44
+ return cache_dir() / hashlib.sha1(str(root).encode()).hexdigest()[:16]
45
+
46
+
47
+ def find_root(start: Path) -> Path:
48
+ p = start if start.is_dir() else start.parent
49
+ for q in [p, *p.parents]:
50
+ if (q / ".git").exists():
51
+ return q
52
+ return p
53
+
54
+
55
+ def code_files(root: Path):
56
+ out = []
57
+ for dirpath, dirnames, filenames in os.walk(root):
58
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
59
+ for f in filenames:
60
+ fp = Path(dirpath) / f
61
+ if fp.suffix in CODE_EXTS and fp.stat().st_size < MAX_FILE_BYTES:
62
+ out.append(fp)
63
+ if len(out) > MAX_FILES:
64
+ print(f"WARNING: {len(out)} code files; graphing first {MAX_FILES} "
65
+ f"(largest dirs may be partial)", file=sys.stderr)
66
+ out = out[:MAX_FILES]
67
+ return out
68
+
69
+
70
+ def load(root: Path):
71
+ d = repo_key(root)
72
+ try:
73
+ graph = json.loads((d / "graph.json").read_text())
74
+ idx = json.loads((d / "index.json").read_text())
75
+ return graph, idx
76
+ except Exception:
77
+ return None, None
78
+
79
+
80
+ def save(root: Path, graph, idx):
81
+ d = repo_key(root)
82
+ d.mkdir(parents=True, exist_ok=True)
83
+ (d / "graph.json").write_text(json.dumps(graph))
84
+ (d / "index.json").write_text(json.dumps(idx))
85
+ rf = roots_file()
86
+ try:
87
+ roots = json.loads(rf.read_text()) if rf.exists() else {}
88
+ except Exception:
89
+ roots = {}
90
+ roots[str(root)] = time.strftime("%Y-%m-%dT%H:%M:%S")
91
+ rf.write_text(json.dumps(roots, indent=1))
92
+
93
+
94
+ def extract_files(paths, root):
95
+ """Extract and normalize source_file to root-relative. graphify stores paths
96
+ relative to the common ancestor of the batch (basename for single/same-dir
97
+ batches), so we resolve via that ancestor. Unattributable ('' semantic) nodes
98
+ are dropped — they'd break incremental dedup."""
99
+ from graphify.extract import extract
100
+ ok_nodes, ok_edges, failed = [], [], []
101
+ resolved = [p.resolve() for p in paths]
102
+ if not resolved:
103
+ return ok_nodes, ok_edges, failed
104
+ common = Path(os.path.commonpath([str(p) for p in resolved])) if len(resolved) > 1 else resolved[0].parent
105
+ if common.is_file():
106
+ common = common.parent
107
+
108
+ def norm(sf):
109
+ if not sf:
110
+ return None
111
+ try:
112
+ return str((common / sf).resolve().relative_to(root))
113
+ except ValueError:
114
+ return None
115
+
116
+ def collect(r):
117
+ for n in r["nodes"]:
118
+ sf = norm(n.get("source_file", ""))
119
+ if sf:
120
+ n["source_file"] = sf
121
+ ok_nodes.append(n)
122
+ for e in r["edges"]:
123
+ sf = norm(e.get("source_file", ""))
124
+ if sf:
125
+ e["source_file"] = sf
126
+ ok_edges.append(e)
127
+
128
+ try:
129
+ collect(extract(paths=resolved, parallel=len(resolved) > 4))
130
+ except Exception:
131
+ for p in resolved:
132
+ common = p.parent
133
+ try:
134
+ collect(extract(paths=[p], parallel=False))
135
+ except Exception:
136
+ failed.append(str(p))
137
+ return ok_nodes, ok_edges, failed
138
+
139
+
140
+ def build(root: Path, only_changed=False):
141
+ files = code_files(root)
142
+ graph, idx = load(root)
143
+ mtimes = {str(f.relative_to(root)): f.stat().st_mtime for f in files}
144
+ if only_changed and graph and idx:
145
+ old = idx.get("mtimes", {})
146
+ changed = [f for f in files if old.get(str(f.relative_to(root))) != f.stat().st_mtime]
147
+ deleted = set(old) - set(mtimes)
148
+ if not changed and not deleted:
149
+ return graph, idx, 0
150
+ drop = {str(f.relative_to(root)) for f in changed} | deleted
151
+ nodes = [n for n in graph["nodes"] if n.get("source_file", "") not in drop]
152
+ edges = [e for e in graph["edges"] if e.get("source_file", "") not in drop]
153
+ new_n, new_e, failed = extract_files(changed, root)
154
+ graph = {"nodes": nodes + new_n, "edges": edges + new_e}
155
+ n_processed = len(changed)
156
+ else:
157
+ new_n, new_e, failed = extract_files(files, root)
158
+ graph = {"nodes": new_n, "edges": new_e}
159
+ n_processed = len(files)
160
+ if failed:
161
+ print(f"WARNING: {len(failed)} files failed extraction: {failed[:5]}", file=sys.stderr)
162
+ idx = {"root": str(root), "mtimes": mtimes, "built": time.strftime("%Y-%m-%dT%H:%M:%S"),
163
+ "failed": failed}
164
+ save(root, graph, idx)
165
+ return graph, idx, n_processed
166
+
167
+
168
+ def ensure(root: Path):
169
+ graph, idx, n = build(root, only_changed=True)
170
+ if n:
171
+ print(f"[graphscout] refreshed {n} file(s)", file=sys.stderr)
172
+ return graph
173
+
174
+
175
+ def touch(target: Path, root: Path):
176
+ """Re-extract one file into its repo's cached graph. No-op when the repo
177
+ has no graph yet (hooks call this on every edit)."""
178
+ g, idx = load(root)
179
+ if not g:
180
+ return
181
+ f = str(target.relative_to(root))
182
+ nodes = [n for n in g["nodes"] if n.get("source_file", "") != f]
183
+ edges = [e for e in g["edges"] if e.get("source_file", "") != f]
184
+ if target.exists() and target.suffix in CODE_EXTS:
185
+ nn, ne, _ = extract_files([target], root)
186
+ nodes += nn
187
+ edges += ne
188
+ if target.exists():
189
+ idx["mtimes"][f] = target.stat().st_mtime
190
+ else:
191
+ idx["mtimes"].pop(f, None)
192
+ save(root, {"nodes": nodes, "edges": edges}, idx)
193
+
194
+
195
+ def watch(root: Path, interval: float = 1.5):
196
+ """Block, keeping root's graph in sync as files change. Yields a status
197
+ line each time it re-syncs (empty string on no-op polls). No hook or
198
+ per-edit `touch` call needed while this runs — the opposite of `ensure`'s
199
+ on-demand model. Uses watchdog for instant, low-CPU events when installed
200
+ ($ pip install "graphscout[watch]"); falls back to mtime polling otherwise.
201
+ """
202
+ if not load(root)[0]:
203
+ build(root)
204
+ yield f"[graphscout] initial build of {root}"
205
+
206
+ try:
207
+ from watchdog.events import FileSystemEventHandler
208
+ from watchdog.observers import Observer
209
+ except ImportError:
210
+ while True:
211
+ time.sleep(interval)
212
+ _g, _idx, n = build(root, only_changed=True)
213
+ if n:
214
+ yield f"[graphscout] refreshed {n} file(s)"
215
+ return
216
+
217
+ import queue
218
+ q = queue.Queue()
219
+
220
+ class Handler(FileSystemEventHandler):
221
+ def on_any_event(self, event):
222
+ if not event.is_directory and Path(event.src_path).suffix in CODE_EXTS:
223
+ q.put(1)
224
+
225
+ observer = Observer()
226
+ observer.schedule(Handler(), str(root), recursive=True)
227
+ observer.start()
228
+ try:
229
+ while True:
230
+ q.get()
231
+ time.sleep(0.3) # debounce bursts (saves, formatters, git checkouts)
232
+ while not q.empty():
233
+ q.get_nowait()
234
+ _g, _idx, n = build(root, only_changed=True)
235
+ if n:
236
+ yield f"[graphscout] refreshed {n} file(s)"
237
+ finally:
238
+ observer.stop()
239
+ observer.join()
@@ -0,0 +1,84 @@
1
+ """MCP server exposing graphscout queries as tools, for agents that speak MCP
2
+ instead of shelling out. Requires the `mcp` extra: pip install graphscout[mcp]
3
+
4
+ Run: graphscout mcp (stdio transport)
5
+ """
6
+ from pathlib import Path
7
+
8
+ from . import core, queries
9
+
10
+ try:
11
+ from mcp.server.fastmcp import FastMCP
12
+ except ImportError as e: # pragma: no cover
13
+ raise SystemExit(
14
+ "MCP support needs the optional dependency: pip install graphscout[mcp]"
15
+ ) from e
16
+
17
+ server = FastMCP(
18
+ "graphscout",
19
+ instructions=(
20
+ "Cached tree-sitter code graphs. Query structure (outlines, symbols, "
21
+ "call edges, imports) instead of reading whole files; then read only "
22
+ "the located line ranges."
23
+ ),
24
+ )
25
+
26
+
27
+ def _root(directory: str) -> Path:
28
+ return core.find_root(Path(directory).resolve())
29
+
30
+
31
+ @server.tool()
32
+ def graph_map(directory: str) -> str:
33
+ """Repo overview: node/edge counts, per-directory breakdown, top hub symbols."""
34
+ root = _root(directory)
35
+ return queries.q_map(root, core.ensure(root))
36
+
37
+
38
+ @server.tool()
39
+ def file_outline(path: str) -> str:
40
+ """Outline of one source file: definitions with line ranges."""
41
+ target = Path(path).resolve()
42
+ root = core.find_root(target)
43
+ return queries.q_file(root, core.ensure(root), target)
44
+
45
+
46
+ @server.tool()
47
+ def find_symbol(name: str, directory: str = ".") -> str:
48
+ """Locate a symbol by substring -> file:lines."""
49
+ root = _root(directory)
50
+ return queries.q_sym(root, core.ensure(root), name)
51
+
52
+
53
+ @server.tool()
54
+ def callers(name: str, directory: str = ".") -> str:
55
+ """Who calls this symbol (matched by substring)."""
56
+ root = _root(directory)
57
+ return queries.q_calls(root, core.ensure(root), name, "callers")
58
+
59
+
60
+ @server.tool()
61
+ def callees(name: str, directory: str = ".") -> str:
62
+ """What this symbol calls (matched by substring)."""
63
+ root = _root(directory)
64
+ return queries.q_calls(root, core.ensure(root), name, "callees")
65
+
66
+
67
+ @server.tool()
68
+ def file_deps(path: str) -> str:
69
+ """Imports of a source file."""
70
+ target = Path(path).resolve()
71
+ root = core.find_root(target)
72
+ return queries.q_deps(root, core.ensure(root), target)
73
+
74
+
75
+ @server.tool()
76
+ def build_graph(directory: str) -> str:
77
+ """Full (re)build of a repo's graph. Run once per repo; queries refresh incrementally."""
78
+ root = _root(directory)
79
+ g, _idx, n = core.build(root)
80
+ return f"built {root}: {n} files -> {len(g['nodes'])} nodes, {len(g['edges'])} edges"
81
+
82
+
83
+ def run():
84
+ server.run()
@@ -0,0 +1,81 @@
1
+ """Query functions over a built graph. Each returns a plain string ready to
2
+ show to a human or an agent — the CLI prints it, the MCP server returns it."""
3
+ from collections import Counter
4
+ from pathlib import Path
5
+
6
+ from . import core
7
+
8
+
9
+ def loc(n):
10
+ return f"{n.get('source_file', '?')}:{n.get('source_location', '?')}"
11
+
12
+
13
+ def fmt_node(n):
14
+ return f"{n.get('label', n['id'])} [{n.get('type', n.get('_origin', '?'))}] {loc(n)}"
15
+
16
+
17
+ def fresh_graph(root: Path):
18
+ return core.ensure(root)
19
+
20
+
21
+ def q_map(root: Path, g) -> str:
22
+ nodes, edges = g["nodes"], g["edges"]
23
+ deg = Counter()
24
+ for e in edges:
25
+ deg[e["source"]] += 1
26
+ deg[e["target"]] += 1
27
+ byid = {n["id"]: n for n in nodes}
28
+ per_dir = Counter(n.get("source_file", "?").split("/")[0] for n in nodes)
29
+ rels = Counter(e.get("relation", "?") for e in edges)
30
+ lines = [f"{root} — {len(nodes)} nodes, {len(edges)} edges",
31
+ f"per-dir: {dict(per_dir.most_common(12))}",
32
+ f"relations: {dict(rels.most_common())}",
33
+ "top hubs:"]
34
+ for nid, d in deg.most_common(15):
35
+ n = byid.get(nid)
36
+ if n:
37
+ lines.append(f" {d:4d} {fmt_node(n)}")
38
+ return "\n".join(lines)
39
+
40
+
41
+ def q_file(root: Path, g, target: Path) -> str:
42
+ f = str(target.resolve().relative_to(root))
43
+ mine = [n for n in g["nodes"] if n.get("source_file", "") == f]
44
+ if not mine:
45
+ return f"(no graph entries for {f} — unsupported language or empty; read it directly)"
46
+ return "\n".join(fmt_node(n) for n in sorted(mine, key=lambda x: x.get("source_location", "")))
47
+
48
+
49
+ def q_sym(root: Path, g, query: str, limit: int = 30) -> str:
50
+ q = query.lower()
51
+ hits = [n for n in g["nodes"] if q in n.get("label", "").lower() or q in n["id"].lower()]
52
+ if not hits:
53
+ return f"no symbol matching '{query}' — try grep"
54
+ lines = [fmt_node(n) for n in hits[:limit]]
55
+ if len(hits) > limit:
56
+ lines.append(f"... {len(hits) - limit} more (narrow the query)")
57
+ return "\n".join(lines)
58
+
59
+
60
+ def q_calls(root: Path, g, query: str, direction: str, limit: int = 40) -> str:
61
+ """direction: 'callers' (who calls it) or 'callees' (what it calls)."""
62
+ q = query.lower()
63
+ byid = {n["id"]: n for n in g["nodes"]}
64
+ key, other = ("target", "source") if direction == "callers" else ("source", "target")
65
+ hits = [e for e in g["edges"] if e.get("relation") == "calls" and q in e[key].lower()]
66
+ if not hits:
67
+ return (f"no call edges matching '{query}' "
68
+ "(dynamic dispatch isn't captured — fall back to grep)")
69
+ lines = []
70
+ for e in hits[:limit]:
71
+ n = byid.get(e[other])
72
+ lines.append(fmt_node(n) if n else
73
+ f"{e[other]} ({e.get('source_file', '?')}:{e.get('source_location', '?')})")
74
+ return "\n".join(lines)
75
+
76
+
77
+ def q_deps(root: Path, g, target: Path) -> str:
78
+ f = str(target.resolve().relative_to(root))
79
+ lines = [f"{e['relation']:13s} {e['target']}" for e in g["edges"]
80
+ if e.get("source_file", "") == f and e.get("relation") in ("imports", "imports_from")]
81
+ return "\n".join(lines) if lines else f"(no import edges recorded for {f})"
@@ -0,0 +1,172 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from graphscout import cli, core
9
+
10
+ A_PY = '''\
11
+ import os
12
+
13
+
14
+ def helper(x):
15
+ return os.path.join("a", x)
16
+
17
+
18
+ def main_entry():
19
+ return helper("b")
20
+ '''
21
+
22
+ B_PY = '''\
23
+ from a import helper
24
+
25
+
26
+ class Runner:
27
+ def run(self):
28
+ return helper("c")
29
+ '''
30
+
31
+
32
+ @pytest.fixture
33
+ def repo(tmp_path, monkeypatch):
34
+ monkeypatch.setenv("GRAPHSCOUT_CACHE", str(tmp_path / "cache"))
35
+ root = tmp_path / "repo"
36
+ (root / ".git").mkdir(parents=True)
37
+ (root / "a.py").write_text(A_PY)
38
+ (root / "sub").mkdir()
39
+ (root / "sub" / "b.py").write_text(B_PY)
40
+ return root
41
+
42
+
43
+ def run(capsys, *args):
44
+ rc = cli.main(list(args))
45
+ out = capsys.readouterr().out
46
+ return rc, out
47
+
48
+
49
+ def test_build_and_map(repo, capsys):
50
+ rc, out = run(capsys, "build", str(repo))
51
+ assert rc == 0 and "built" in out and "0 nodes" not in out
52
+ rc, out = run(capsys, "map", str(repo))
53
+ assert "nodes" in out and "top hubs:" in out
54
+
55
+
56
+ def test_file_outline_and_sym(repo, capsys):
57
+ run(capsys, "build", str(repo))
58
+ rc, out = run(capsys, "file", str(repo / "a.py"))
59
+ assert "helper" in out and "main_entry" in out
60
+ rc, out = run(capsys, "sym", "main_entry", str(repo))
61
+ assert "a.py" in out
62
+
63
+
64
+ def test_callers_and_deps(repo, capsys):
65
+ run(capsys, "build", str(repo))
66
+ rc, out = run(capsys, "callers", "helper", str(repo))
67
+ assert "main_entry" in out or "run" in out
68
+ rc, out = run(capsys, "deps", str(repo / "a.py"))
69
+ assert "os" in out
70
+
71
+
72
+ def test_incremental_refresh_no_node_growth(repo, capsys):
73
+ """Repeated ensure must not duplicate nodes; edits must be picked up."""
74
+ run(capsys, "build", str(repo))
75
+ g1, _ = core.load(repo)
76
+ run(capsys, "ensure", str(repo))
77
+ run(capsys, "ensure", str(repo))
78
+ g2, _ = core.load(repo)
79
+ assert len(g2["nodes"]) == len(g1["nodes"])
80
+
81
+ time.sleep(0.01)
82
+ (repo / "a.py").write_text(A_PY + "\n\ndef added_later():\n return 1\n")
83
+ os.utime(repo / "a.py")
84
+ rc, out = run(capsys, "sym", "added_later", str(repo))
85
+ assert "a.py" in out
86
+ g3, _ = core.load(repo)
87
+ dupes = [n for n in g3["nodes"] if "helper" in n.get("label", "")
88
+ and n.get("source_file") == "a.py"]
89
+ assert len(dupes) == 1
90
+
91
+
92
+ def test_deleted_file_dropped(repo, capsys):
93
+ run(capsys, "build", str(repo))
94
+ (repo / "sub" / "b.py").unlink()
95
+ rc, out = run(capsys, "sym", "Runner", str(repo))
96
+ assert "no symbol matching" in out
97
+
98
+
99
+ def test_touch_single_file(repo, capsys):
100
+ run(capsys, "build", str(repo))
101
+ time.sleep(0.01)
102
+ (repo / "a.py").write_text(A_PY.replace("main_entry", "renamed_entry"))
103
+ core.touch(repo / "a.py", repo)
104
+ g, idx = core.load(repo)
105
+ labels = {n.get("label", "") for n in g["nodes"] if n.get("source_file") == "a.py"}
106
+ assert any("renamed_entry" in l for l in labels)
107
+ assert not any("main_entry" in l for l in labels)
108
+
109
+
110
+ def test_agent_snippet_and_version(repo, capsys):
111
+ rc, out = run(capsys, "agent")
112
+ assert "graphscout map" in out and "graphscout sym" in out
113
+ rc, out = run(capsys, "--version")
114
+ assert out.startswith("graphscout ")
115
+
116
+
117
+ def test_unknown_command(repo, capsys):
118
+ rc, out = run(capsys, "frobnicate")
119
+ assert rc == 2
120
+
121
+
122
+ def test_cache_is_root_scoped(repo, capsys):
123
+ run(capsys, "build", str(repo))
124
+ d = core.repo_key(repo)
125
+ assert (d / "graph.json").exists()
126
+ roots = json.loads(core.roots_file().read_text())
127
+ assert str(repo) in roots
128
+
129
+
130
+ def test_watch_polling_refresh(repo, monkeypatch):
131
+ """Without watchdog installed, watch() falls back to mtime polling."""
132
+ monkeypatch.setitem(__import__("sys").modules, "watchdog", None)
133
+ monkeypatch.setattr(core.time, "sleep", lambda _s: None)
134
+
135
+ gen = core.watch(repo, interval=0.01)
136
+ first = next(gen)
137
+ assert "initial build" in first
138
+
139
+ time.sleep(0.01)
140
+ (repo / "a.py").write_text(A_PY + "\n\ndef watched_fn():\n return 1\n")
141
+ os.utime(repo / "a.py")
142
+ second = next(gen)
143
+ assert "refreshed" in second
144
+ gen.close()
145
+
146
+ g, _ = core.load(repo)
147
+ assert any("watched_fn" in n.get("label", "") for n in g["nodes"])
148
+
149
+
150
+ def test_install_uninstall_json_agent(tmp_path, monkeypatch):
151
+ from graphscout import agents
152
+
153
+ cursor_path = tmp_path / "cursor" / "mcp.json"
154
+ monkeypatch.setitem(agents.AGENTS, "cursor", {"kind": "json", "path": cursor_path})
155
+
156
+ log = agents.install(["cursor"])
157
+ assert any("wired" in line for line in log)
158
+ cfg = json.loads(cursor_path.read_text())
159
+ assert cfg["mcpServers"]["graphscout"]["command"] == "graphscout"
160
+
161
+ log = agents.uninstall(["cursor"])
162
+ assert any("removed" in line for line in log)
163
+ cfg = json.loads(cursor_path.read_text())
164
+ assert "graphscout" not in cfg["mcpServers"]
165
+
166
+
167
+ def test_detect_skips_missing_cli_agent(monkeypatch):
168
+ from graphscout import agents
169
+
170
+ monkeypatch.setattr(agents.shutil, "which", lambda _b: None)
171
+ present = agents.detect()
172
+ assert all(ok is False for name, ok in present.items() if agents.AGENTS[name]["kind"] == "cli")