ctxgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ctxgraph/mcp/server.py ADDED
@@ -0,0 +1,216 @@
1
+ """
2
+ Phase 2: MCP (Model Context Protocol) Server
3
+
4
+ This server implements the MCP protocol to allow Claude Desktop
5
+ and other MCP-compatible clients to query the knowledge graph
6
+ directly through tools instead of static context capsules.
7
+
8
+ Protocol: https://modelcontextprotocol.io
9
+
10
+ Usage:
11
+ ctx serve # Start MCP server (stdio mode)
12
+ ctx serve --port 8080 # Start MCP server (SSE mode)
13
+
14
+ Claude Desktop config:
15
+ {
16
+ "mcpServers": {
17
+ "ctxgraph": {
18
+ "command": "ctx",
19
+ "args": ["serve"]
20
+ }
21
+ }
22
+ }
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+ from typing import Any, Optional
31
+
32
+ try:
33
+ import mcp.types as types
34
+ from mcp.server import NotificationOptions, Server
35
+ from mcp.server.models import InitializationOptions
36
+
37
+ HAS_MCP = True
38
+ except ImportError:
39
+ HAS_MCP = False
40
+
41
+ from ctxgraph.capsule.renderer import render_capsule, render_project_overview
42
+ from ctxgraph.clients.models import ModelMode, get_mode_config
43
+ from ctxgraph.graph.builder import get_storage
44
+ from ctxgraph.graph.query import search_relevant_nodes
45
+
46
+
47
+ def create_server(repo_path: Optional[str] = None) -> Optional[Any]:
48
+ if not HAS_MCP:
49
+ return None
50
+
51
+ path = Path(repo_path).resolve() if repo_path else Path.cwd()
52
+ storage = get_storage(path)
53
+
54
+ server = Server("ctxgraph")
55
+
56
+ @server.list_tools()
57
+ async def list_tools() -> list[types.Tool]:
58
+ return [
59
+ types.Tool(
60
+ name="search_graph",
61
+ description="Search the codebase knowledge graph for relevant files, classes, and functions",
62
+ inputSchema={
63
+ "type": "object",
64
+ "properties": {
65
+ "query": {
66
+ "type": "string",
67
+ "description": "Search query describing what you're looking for",
68
+ },
69
+ "max_results": {
70
+ "type": "integer",
71
+ "description": "Maximum number of results",
72
+ "default": 15,
73
+ },
74
+ },
75
+ "required": ["query"],
76
+ },
77
+ ),
78
+ types.Tool(
79
+ name="get_context_capsule",
80
+ description="Generate a token-efficient context capsule for a specific task",
81
+ inputSchema={
82
+ "type": "object",
83
+ "properties": {
84
+ "query": {
85
+ "type": "string",
86
+ "description": "The task description for context generation",
87
+ },
88
+ "mode": {
89
+ "type": "string",
90
+ "enum": ["fast", "balanced", "deep"],
91
+ "description": "Detail level: fast (minimal), balanced, deep (comprehensive)",
92
+ "default": "balanced",
93
+ },
94
+ },
95
+ "required": ["query"],
96
+ },
97
+ ),
98
+ types.Tool(
99
+ name="get_file_dependencies",
100
+ description="Get the dependency graph for a specific file",
101
+ inputSchema={
102
+ "type": "object",
103
+ "properties": {
104
+ "file_path": {
105
+ "type": "string",
106
+ "description": "Path to the file (relative to repo root)",
107
+ },
108
+ },
109
+ "required": ["file_path"],
110
+ },
111
+ ),
112
+ types.Tool(
113
+ name="get_project_overview",
114
+ description="Get a high-level overview of the entire codebase structure",
115
+ inputSchema={
116
+ "type": "object",
117
+ "properties": {},
118
+ },
119
+ ),
120
+ ]
121
+
122
+ @server.call_tool()
123
+ async def call_tool(
124
+ name: str, arguments: dict
125
+ ) -> list[types.TextContent]:
126
+ nonlocal storage
127
+ if storage is None:
128
+ storage = get_storage(path)
129
+
130
+ if storage is None:
131
+ return [types.TextContent(
132
+ type="text",
133
+ text="No graph found. Run `ctx build` first.",
134
+ )]
135
+
136
+ if name == "search_graph":
137
+ query = arguments.get("query", "")
138
+ max_results = arguments.get("max_results", 15)
139
+ results = search_relevant_nodes(storage, query, max_nodes=max_results)
140
+ lines = [f"Search results for: {query}", ""]
141
+ for node, score in results:
142
+ lines.append(f" [{node.type}] {node.name} ({node.path}) - score: {score}")
143
+ if node.summary:
144
+ lines.append(f" {node.summary}")
145
+ return [types.TextContent(type="text", text="\n".join(lines))]
146
+
147
+ elif name == "get_context_capsule":
148
+ query = arguments.get("query", "")
149
+ mode_str = arguments.get("mode", "balanced")
150
+ mode = ModelMode.from_str(mode_str)
151
+ cfg = get_mode_config(mode)
152
+ capsule = render_capsule(storage, query, max_nodes=cfg["max_nodes"])
153
+ return [types.TextContent(type="text", text=capsule)]
154
+
155
+ elif name == "get_file_dependencies":
156
+ file_path = arguments.get("file_path", "")
157
+ node_id = f"file:{file_path}"
158
+ node = storage.get_node(node_id)
159
+ if not node:
160
+ return [types.TextContent(
161
+ type="text",
162
+ text=f"File not found in graph: {file_path}",
163
+ )]
164
+ edges = storage.get_edges_for_nodes({node_id})
165
+ lines = [f"Dependencies for: {file_path}", ""]
166
+ for e in edges:
167
+ target = storage.get_node(e.target_id)
168
+ source = storage.get_node(e.source_id)
169
+ if target and source:
170
+ lines.append(f" {source.name} --{e.relation}--> {target.name}")
171
+ return [types.TextContent(type="text", text="\n".join(lines))]
172
+
173
+ elif name == "get_project_overview":
174
+ overview = render_project_overview(storage)
175
+ return [types.TextContent(type="text", text=overview)]
176
+
177
+ else:
178
+ return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
179
+
180
+ return server
181
+
182
+
183
+ def run_server(repo_path: Optional[str] = None, port: Optional[int] = None):
184
+ if not HAS_MCP:
185
+ print(
186
+ "MCP support requires additional dependencies.\n"
187
+ "Install with: pip install ctxgraph[mcp]",
188
+ file=sys.stderr,
189
+ )
190
+ sys.exit(1)
191
+
192
+ server = create_server(repo_path)
193
+ if server is None:
194
+ print("Failed to create MCP server", file=sys.stderr)
195
+ sys.exit(1)
196
+
197
+ from mcp.server.stdio import stdio_server
198
+
199
+ import anyio
200
+
201
+ async def _run():
202
+ async with stdio_server() as (read_stream, write_stream):
203
+ await server.run(
204
+ read_stream,
205
+ write_stream,
206
+ InitializationOptions(
207
+ server_name="ctxgraph",
208
+ server_version="0.1.0",
209
+ capabilities=server.get_capabilities(
210
+ notification_options=NotificationOptions(),
211
+ experimental_capabilities={},
212
+ ),
213
+ ),
214
+ )
215
+
216
+ anyio.run(_run)
File without changes
@@ -0,0 +1,282 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from ctxgraph.graph.storage import Storage
7
+
8
+
9
+ def render_view(storage: Storage) -> str:
10
+ nodes = storage.get_all_nodes()
11
+ edges = storage.get_all_edges()
12
+
13
+ file_nodes = [n for n in nodes if n.type == "file"]
14
+ symbol_nodes = [n for n in nodes if n.type != "file"]
15
+
16
+ graph_data = {
17
+ "nodes": [],
18
+ "links": [],
19
+ }
20
+
21
+ file_node_ids = {n.id for n in file_nodes}
22
+
23
+ for node in file_nodes:
24
+ graph_data["nodes"].append(
25
+ {
26
+ "id": node.id,
27
+ "label": _short_path(node.path or node.name),
28
+ "type": "file",
29
+ "summary": (node.summary or "")[:100],
30
+ "importance": node.importance,
31
+ }
32
+ )
33
+
34
+ for node in symbol_nodes:
35
+ graph_data["nodes"].append(
36
+ {
37
+ "id": node.id,
38
+ "label": node.name,
39
+ "type": node.type,
40
+ "summary": (node.summary or "")[:100],
41
+ "importance": node.importance,
42
+ }
43
+ )
44
+
45
+ seen_links = set()
46
+ for edge in edges:
47
+ if edge.source_id in file_node_ids or edge.target_id in file_node_ids:
48
+ key = (edge.source_id, edge.target_id)
49
+ if key not in seen_links:
50
+ seen_links.add(key)
51
+ graph_data["links"].append(
52
+ {
53
+ "source": edge.source_id,
54
+ "target": edge.target_id,
55
+ "relation": edge.relation,
56
+ }
57
+ )
58
+
59
+ json_data = json.dumps(graph_data, indent=2)
60
+
61
+ template = _get_html_template()
62
+ return template.replace("/* GRAPH_DATA */", json_data)
63
+
64
+
65
+ def _short_path(path: str) -> str:
66
+ parts = path.split("/")
67
+ if len(parts) > 3:
68
+ return "/".join(parts[:2] + ["..."] + parts[-1:])
69
+ return path
70
+
71
+
72
+ def _get_html_template() -> str:
73
+ return """<!DOCTYPE html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="UTF-8">
77
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
78
+ <title>ctxgraph - Knowledge Graph</title>
79
+ <script src="https://d3js.org/d3.v7.min.js"></script>
80
+ <style>
81
+ * { margin: 0; padding: 0; box-sizing: border-box; }
82
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0d1117; color: #c9d1d9; overflow: hidden; }
83
+ #container { width: 100vw; height: 100vh; position: relative; }
84
+ svg { width: 100%; height: 100%; }
85
+ #toolbar { position: absolute; top: 16px; left: 16px; z-index: 10; display: flex; gap: 8px; align-items: center; background: #161b22; padding: 12px 16px; border-radius: 8px; border: 1px solid #30363d; }
86
+ #toolbar input { background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 12px; border-radius: 4px; width: 220px; font-size: 13px; }
87
+ #toolbar select { background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 8px; border-radius: 4px; font-size: 13px; }
88
+ #toolbar label { font-size: 13px; color: #8b949e; }
89
+ #legend { position: absolute; bottom: 16px; left: 16px; z-index: 10; background: #161b22; padding: 12px; border-radius: 8px; border: 1px solid #30363d; font-size: 12px; display: flex; gap: 16px; }
90
+ .legend-item { display: flex; align-items: center; gap: 6px; }
91
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; }
92
+ #tooltip { position: absolute; z-index: 20; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px; font-size: 13px; max-width: 400px; pointer-events: none; display: none; box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
93
+ #tooltip .tt-name { font-weight: 600; color: #58a6ff; margin-bottom: 4px; }
94
+ #tooltip .tt-type { color: #8b949e; font-size: 11px; margin-bottom: 4px; }
95
+ #tooltip .tt-summary { color: #c9d1d9; font-size: 12px; }
96
+ #stats { position: absolute; bottom: 16px; right: 16px; z-index: 10; background: #161b22; padding: 8px 12px; border-radius: 8px; border: 1px solid #30363d; font-size: 11px; color: #8b949e; }
97
+ .link { stroke-opacity: 0.4; }
98
+ .link.imports { stroke: #58a6ff; }
99
+ .link.calls { stroke: #3fb950; }
100
+ .link.defines { stroke: #d29922; }
101
+ .link.extends { stroke: #bc8cff; }
102
+ .node { cursor: pointer; transition: opacity 0.2s; }
103
+ .node:hover { opacity: 0.8; }
104
+ .node-label { font-size: 11px; fill: #8b949e; pointer-events: none; text-shadow: 0 1px 2px #0d1117, 0 -1px 2px #0d1117, 1px 0 2px #0d1117, -1px 0 2px #0d1117; }
105
+ .node.highlighted .node-label { fill: #c9d1d9; font-weight: 600; }
106
+ .link.highlighted { stroke-opacity: 0.8; }
107
+ </style>
108
+ </head>
109
+ <body>
110
+ <div id="container">
111
+ <div id="toolbar">
112
+ <label>Search:</label>
113
+ <input type="text" id="search" placeholder="Search nodes..." oninput="filterGraph(this.value)">
114
+ <label>Filter:</label>
115
+ <select id="filterType" onchange="filterByType(this.value)">
116
+ <option value="all">All</option>
117
+ <option value="file">Files</option>
118
+ <option value="class">Classes</option>
119
+ <option value="function">Functions</option>
120
+ </select>
121
+ </div>
122
+ <div id="legend">
123
+ <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> File</div>
124
+ <div class="legend-item"><div class="legend-dot" style="background:#d29922"></div> Class</div>
125
+ <div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> Function</div>
126
+ <div class="legend-item"><svg width="20" height="2"><line x1="0" y1="1" x2="20" y2="1" stroke="#58a6ff" stroke-width="1.5" stroke-dasharray="4,2"/></svg> Import</div>
127
+ <div class="legend-item"><svg width="20" height="2"><line x1="0" y1="1" x2="20" y2="1" stroke="#3fb950" stroke-width="1.5" stroke-dasharray="2,2"/></svg> Call</div>
128
+ </div>
129
+ <div id="stats"></div>
130
+ <div id="tooltip">
131
+ <div class="tt-name"></div>
132
+ <div class="tt-type"></div>
133
+ <div class="tt-summary"></div>
134
+ </div>
135
+ <svg></svg>
136
+ </div>
137
+ <script>
138
+ const graphData = /* GRAPH_DATA */;
139
+ const width = window.innerWidth;
140
+ const height = window.innerHeight;
141
+
142
+ const svg = d3.select("svg");
143
+ const g = svg.append("g");
144
+
145
+ const zoom = d3.zoom()
146
+ .scaleExtent([0.1, 4])
147
+ .on("zoom", (event) => g.attr("transform", event.transform));
148
+
149
+ svg.call(zoom);
150
+
151
+ const colorMap = { file: "#58a6ff", class: "#d29922", function: "#3fb950", module: "#bc8cff" };
152
+
153
+ const simulation = d3.forceSimulation(graphData.nodes)
154
+ .force("link", d3.forceLink(graphData.links).id(d => d.id).distance(d => {
155
+ return d.relation === "imports" ? 100 : 80;
156
+ }))
157
+ .force("charge", d3.forceManyBody().strength(-200))
158
+ .force("center", d3.forceCenter(width / 2, height / 2))
159
+ .force("collision", d3.forceCollide(20));
160
+
161
+ const link = g.append("g")
162
+ .selectAll("line")
163
+ .data(graphData.links)
164
+ .join("line")
165
+ .attr("class", d => `link ${d.relation}`)
166
+ .attr("stroke-width", 1.5);
167
+
168
+ const node = g.append("g")
169
+ .selectAll("g")
170
+ .data(graphData.nodes)
171
+ .join("g")
172
+ .attr("class", "node")
173
+ .call(d3.drag()
174
+ .on("start", (event, d) => {
175
+ if (!event.active) simulation.alphaTarget(0.3).restart();
176
+ d.fx = d.x;
177
+ d.fy = d.y;
178
+ })
179
+ .on("drag", (event, d) => {
180
+ d.fx = event.x;
181
+ d.fy = event.y;
182
+ })
183
+ .on("end", (event, d) => {
184
+ if (!event.active) simulation.alphaTarget(0);
185
+ d.fx = null;
186
+ d.fy = null;
187
+ })
188
+ );
189
+
190
+ node.append("circle")
191
+ .attr("r", d => 5 + (d.importance || 0.5) * 8)
192
+ .attr("fill", d => colorMap[d.type] || "#8b949e")
193
+ .attr("stroke", "#161b22")
194
+ .attr("stroke-width", 1.5);
195
+
196
+ node.append("text")
197
+ .attr("class", "node-label")
198
+ .text(d => d.label)
199
+ .attr("dx", d => 10 + (d.importance || 0.5) * 5)
200
+ .attr("dy", 4);
201
+
202
+ node.on("mouseover", function(event, d) {
203
+ const tt = d3.select("#tooltip");
204
+ tt.style("display", "block");
205
+ tt.select(".tt-name").text(d.label);
206
+ tt.select(".tt-type").text(`Type: ${d.type}`);
207
+ tt.select(".tt-summary").text(d.summary || "");
208
+
209
+ const connected = new Set();
210
+ graphData.links.forEach(l => {
211
+ const sid = typeof l.source === 'object' ? l.source.id : l.source;
212
+ const tid = typeof l.target === 'object' ? l.target.id : l.target;
213
+ if (sid === d.id) connected.add(tid);
214
+ if (tid === d.id) connected.add(sid);
215
+ });
216
+
217
+ d3.selectAll(".node").each(function(n) {
218
+ if (n.id === d.id || connected.has(n.id)) {
219
+ d3.select(this).classed("highlighted", true);
220
+ d3.select(this).select("circle").attr("opacity", 1);
221
+ d3.select(this).select("text").attr("opacity", 1);
222
+ } else {
223
+ d3.select(this).select("circle").attr("opacity", 0.15);
224
+ d3.select(this).select("text").attr("opacity", 0.15);
225
+ }
226
+ });
227
+
228
+ d3.selectAll(".link").each(function(l) {
229
+ const sid = typeof l.source === 'object' ? l.source.id : l.source;
230
+ const tid = typeof l.target === 'object' ? l.target.id : l.target;
231
+ if (sid === d.id || tid === d.id) {
232
+ d3.select(this).classed("highlighted", true);
233
+ }
234
+ });
235
+ })
236
+ .on("mousemove", function(event) {
237
+ d3.select("#tooltip")
238
+ .style("left", (event.pageX + 12) + "px")
239
+ .style("top", (event.pageY - 10) + "px");
240
+ })
241
+ .on("mouseout", function() {
242
+ d3.select("#tooltip").style("display", "none");
243
+ d3.selectAll(".node").each(function(n) {
244
+ d3.select(this).classed("highlighted", false);
245
+ d3.select(this).select("circle").attr("opacity", 1);
246
+ d3.select(this).select("text").attr("opacity", 1);
247
+ });
248
+ d3.selectAll(".link").classed("highlighted", false);
249
+ });
250
+
251
+ simulation.on("tick", () => {
252
+ link
253
+ .attr("x1", d => d.source.x)
254
+ .attr("y1", d => d.source.y)
255
+ .attr("x2", d => d.target.x)
256
+ .attr("y2", d => d.target.y);
257
+ node.attr("transform", d => `translate(${d.x},${d.y})`);
258
+ });
259
+
260
+ d3.select("#stats").text(`Nodes: ${graphData.nodes.length} | Edges: ${graphData.links.length}`);
261
+
262
+ window.filterGraph = function(query) {
263
+ const q = query.toLowerCase();
264
+ d3.selectAll(".node").each(function(d) {
265
+ const match = !q || d.label.toLowerCase().includes(q) || (d.summary && d.summary.toLowerCase().includes(q));
266
+ d3.select(this).style("display", match ? null : "none");
267
+ });
268
+ };
269
+
270
+ window.filterByType = function(type) {
271
+ d3.selectAll(".node").each(function(d) {
272
+ const match = type === "all" || d.type === type;
273
+ d3.select(this).style("display", match ? null : "none");
274
+ });
275
+ };
276
+
277
+ window.addEventListener("resize", () => {
278
+ svg.attr("width", window.innerWidth).attr("height", window.innerHeight);
279
+ });
280
+ </script>
281
+ </body>
282
+ </html>"""
File without changes
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from ctxgraph.clients.models import ModelMode, get_mode_config
8
+ from ctxgraph.graph.builder import get_storage
9
+
10
+ try:
11
+ from ctxgraph.capsule.renderer import render_capsule, render_project_overview
12
+
13
+ HAS_CAPSULE = True
14
+ except ImportError:
15
+ HAS_CAPSULE = False
16
+
17
+
18
+ def main():
19
+ args = sys.argv[1:]
20
+
21
+ if not args:
22
+ print("Usage: ccg [--mode fast|balanced|deep] <query>")
23
+ print(" ccg --overview")
24
+ print(" ccg --chat <query>")
25
+ sys.exit(1)
26
+
27
+ mode = ModelMode.BALANCED
28
+ is_overview = False
29
+ is_chat = False
30
+ query_parts = []
31
+
32
+ for arg in args:
33
+ if arg == "--overview" or arg == "-o":
34
+ is_overview = True
35
+ elif arg == "--chat" or arg == "-c":
36
+ is_chat = True
37
+ elif arg.startswith("--mode="):
38
+ mode = ModelMode.from_str(arg.split("=", 1)[1])
39
+ elif arg == "--mode" or arg == "-m":
40
+ continue
41
+ else:
42
+ query_parts.append(arg)
43
+
44
+ if not is_overview and not query_parts:
45
+ print("No query provided.")
46
+ sys.exit(1)
47
+
48
+ query = " ".join(query_parts)
49
+ mode_cfg = get_mode_config(mode)
50
+
51
+ repo_path = Path.cwd()
52
+ capsule_text = ""
53
+ storage = None
54
+
55
+ try:
56
+ storage = get_storage(repo_path)
57
+ except Exception:
58
+ storage = None
59
+
60
+ if storage:
61
+ if is_overview:
62
+ capsule_text = render_project_overview(storage)
63
+ else:
64
+ capsule_text = render_capsule(
65
+ storage,
66
+ query,
67
+ max_nodes=mode_cfg["max_nodes"],
68
+ )
69
+ else:
70
+ capsule_text = (
71
+ "[INFO]No ctxgraph data found. "
72
+ "Run `ctx build` for enriched context."
73
+ )
74
+
75
+ mode_hint = {
76
+ ModelMode.FAST: "Use fast, concise reasoning. Prioritize direct answers.",
77
+ ModelMode.BALANCED: "",
78
+ ModelMode.DEEP: "Take your time. Analyze deeply before responding.",
79
+ }.get(mode, "")
80
+
81
+ augmented_prompt = (
82
+ f"[CONTEXT]\n{capsule_text}\n[/CONTEXT]\n\n"
83
+ f"TASK: {query}\n\n"
84
+ f"{mode_hint}\n" if mode_hint else ""
85
+ )
86
+
87
+ claude_cmd = _find_claude()
88
+ if not claude_cmd:
89
+ print(
90
+ "[red]Claude CLI not found. Install it or ensure it's in PATH.[/red]",
91
+ file=sys.stderr,
92
+ )
93
+ sys.exit(1)
94
+
95
+ if is_chat:
96
+ context_file = repo_path / ".ctxgraph" / "context.md"
97
+ context_file.parent.mkdir(parents=True, exist_ok=True)
98
+ context_file.write_text(capsule_text, encoding="utf-8")
99
+
100
+ system_msg = (
101
+ f"Project context is loaded from {context_file}.\n"
102
+ f"Read it first, then help the user.\n"
103
+ f"{mode_hint}"
104
+ )
105
+
106
+ proc = subprocess.Popen(
107
+ [claude_cmd, "-p", system_msg],
108
+ stdin=sys.stdin,
109
+ stdout=sys.stdout,
110
+ stderr=sys.stderr,
111
+ )
112
+ proc.wait()
113
+ else:
114
+ proc = subprocess.run(
115
+ [claude_cmd, "-p", augmented_prompt],
116
+ capture_output=False,
117
+ )
118
+ sys.exit(proc.returncode)
119
+
120
+
121
+ def _find_claude() -> str | None:
122
+ claude_names = ["claude", "claude.exe"]
123
+ for name in claude_names:
124
+ try:
125
+ result = subprocess.run(
126
+ ["where", name] if sys.platform == "win32" else ["which", name],
127
+ capture_output=True,
128
+ text=True,
129
+ timeout=5,
130
+ )
131
+ if result.returncode == 0:
132
+ return name
133
+ except (subprocess.TimeoutExpired, FileNotFoundError):
134
+ pass
135
+ return None
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()