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.
- graphscout-0.2.0/.github/workflows/ci.yml +22 -0
- graphscout-0.2.0/.gitignore +9 -0
- graphscout-0.2.0/LICENSE +21 -0
- graphscout-0.2.0/PKG-INFO +144 -0
- graphscout-0.2.0/README.md +111 -0
- graphscout-0.2.0/integrations/claude-code/graphscout-touch.sh +13 -0
- graphscout-0.2.0/integrations/claude-code/settings-snippet.json +17 -0
- graphscout-0.2.0/pyproject.toml +44 -0
- graphscout-0.2.0/src/graphscout/__init__.py +4 -0
- graphscout-0.2.0/src/graphscout/agents.py +134 -0
- graphscout-0.2.0/src/graphscout/cli.py +125 -0
- graphscout-0.2.0/src/graphscout/core.py +239 -0
- graphscout-0.2.0/src/graphscout/mcp_server.py +84 -0
- graphscout-0.2.0/src/graphscout/queries.py +81 -0
- graphscout-0.2.0/tests/test_graphscout.py +172 -0
|
@@ -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
|
graphscout-0.2.0/LICENSE
ADDED
|
@@ -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,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,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")
|