cancanneed 0.1.0

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.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # cancanneed — 代码仓库压缩骨架提取器
2
+
3
+ ~2K tokens 看清一个项目的全貌。基于 [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) 的知识图谱,
4
+ 一次查询提取语言分布、入口函数、高扇入热点、调用链路、分层聚类、复杂度热点。
5
+
6
+ ## 一条命令安装
7
+
8
+ **npx(推荐,无需 clone):**
9
+ ```bash
10
+ npx cancanneed /path/to/project
11
+ ```
12
+
13
+ **Hermes Agent:**
14
+ ```bash
15
+ cp ~/cancanneed/hermes_adapter.py ~/hermes-agent/tools/ && hermes # /reset
16
+ ```
17
+
18
+ **Claude Code / Codex CLI:**
19
+ 直接走 codebase-memory-mcp MCP server,然后用 `cancanneed.py` CLI:
20
+ ```bash
21
+ # 先装 CBM (如果还没有)
22
+ curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash
23
+
24
+ # 索引 + 骨架
25
+ git clone https://github.com/user/repo /tmp/repo
26
+ codebase-memory-mcp cli index_repository '{"repo_path":"/tmp/repo","mode":"fast"}' --json
27
+ python3 ~/cancanneed/cancanneed.py /tmp/repo
28
+ ```
29
+
30
+ **CLI 直接使用(任何 agent,零依赖):**
31
+ ```bash
32
+ python3 ~/cancanneed/cancanneed.py /path/to/project # 自动索引 + 骨架
33
+ python3 ~/cancanneed/cancanneed.py project-name # 已有索引的项目
34
+ python3 ~/cancanneed/cancanneed.py # 列出所有可用项目
35
+ python3 ~/cancanneed/cancanneed.py --json /path/to/project # JSON 输出
36
+ python3 ~/cancanneed/cancanneed.py --skill # 输出 SKILL.md
37
+ ```
38
+
39
+ ## 前置条件
40
+
41
+ - Python 3.9+
42
+ - [codebase-memory-mcp](https://github.com/DeusData/codebase-memory-mcp) 二进制(用于首次索引)
43
+ - macOS: `brew install deusdata/codebase-memory-mcp/codebase-memory-mcp`
44
+ - 或: `curl -fsSL https://raw.githubusercontent.com/DeusData/codebase-memory-mcp/main/install.sh | bash`
45
+ - 首次索引需要 10-60s,后续对同一目录秒级返回(增量索引)
46
+
47
+ ## 输出示例
48
+
49
+ ```
50
+ project: Users-lyx-ai-runtime-trace
51
+ root: /Users/lyx/ai-runtime-trace
52
+ indexed: 2026-07-11T02:54:07Z
53
+ nodes: 2255, edges: 4486
54
+
55
+ languages: Python 62, Markdown 57, YAML 21, Bash 11
56
+
57
+ entry points:
58
+ hermes_flow/cli/analyze.py:main
59
+ experiments/agent-pool/cli.py:main
60
+ ...
61
+
62
+ routes: ANY /api/admin/agents, ANY /api/admin/skills
63
+
64
+ hotspots:
65
+ connect (56 in) -- hermes_flow/storage.py
66
+ span (22 in) -- hermes_flow/trace.py
67
+ ...
68
+
69
+ layers:
70
+ core: storage(55 in), schemas(23 in), trace(30 in)
71
+ entry: agent-pool, cli, observer, tools
72
+ ...
73
+
74
+ complexity:
75
+ flow_init -- O(n^5) (hermes_flow/tools.py)
76
+ validate_flow -- O(n^4) (hermes_flow/flow_loader.py)
77
+
78
+ call chains:
79
+ main -> run_benchmark -> run_agent_session_simulated -> span
80
+ main -> _find_runs_dir -> get_runs_dir -> get_project_root
81
+ ```
82
+
83
+ (~2500 chars / ~600 tokens)
84
+
85
+ ## 项目结构
86
+
87
+ ```
88
+ ~/cancanneed/
89
+ cancanneed.py # 核心模块 (纯 Python, 零依赖)
90
+ hermes_adapter.py # Hermes Agent 适配器 (复制到 ~/hermes-agent/tools/)
91
+ skills/SKILL.md # Hermes skill
92
+ README.md # 本文件
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
package/cancanneed.py ADDED
@@ -0,0 +1,427 @@
1
+ #!/usr/bin/env python3
2
+ """cancanneed — codebase skeleton extractor.
3
+
4
+ Extracts ~2K token compressed architectural skeleton from
5
+ codebase-memory-mcp SQLite graph DB. Two modes:
6
+ repo_path: auto-index via CBM binary, then extract
7
+ project: read pre-indexed DB directly
8
+ """
9
+
10
+ import json, os, re, sqlite3, subprocess, sys
11
+ from collections import Counter, defaultdict
12
+
13
+ CACHE_DIR = os.path.expanduser("~/.cache/codebase-memory-mcp")
14
+
15
+ def _find_cbm_binary():
16
+ for p in [
17
+ os.path.expanduser("~/.local/bin/codebase-memory-mcp"),
18
+ os.path.expanduser("~/.hermes/bin/codebase-memory-mcp"),
19
+ "/usr/local/bin/codebase-memory-mcp",
20
+ ]:
21
+ if os.path.isfile(p) and os.access(p, os.X_OK):
22
+ return p
23
+ return None
24
+
25
+ def _cbm_project_name(repo_path):
26
+ name = repo_path.strip("/").replace("/", "-").replace(":", "-")
27
+ name = re.sub(r"-+", "-", name)
28
+ return re.sub(r"^-", "", name)
29
+
30
+ def _ensure_indexed(repo_path):
31
+ cbm = _find_cbm_binary()
32
+ if not cbm:
33
+ return None, "CBM binary not found. Install codebase-memory-mcp first."
34
+ repo_path = os.path.abspath(repo_path)
35
+ if not os.path.isdir(repo_path):
36
+ return None, "Not a directory: %s" % repo_path
37
+ pname = _cbm_project_name(repo_path)
38
+ db_path = os.path.join(CACHE_DIR, pname + ".db")
39
+ if os.path.isfile(db_path):
40
+ return db_path, None
41
+ print("cancanneed: indexing %s ..." % repo_path, file=sys.stderr)
42
+ try:
43
+ cmd = [cbm, "cli", "index_repository",
44
+ json.dumps({"repo_path": repo_path, "mode": "fast"}), "--json"]
45
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
46
+ if proc.returncode != 0:
47
+ return None, "CBM index failed (%d): %s" % (proc.returncode, proc.stderr[:200])
48
+ if not os.path.isfile(db_path):
49
+ return None, "CBM finished but .db not generated"
50
+ return db_path, None
51
+ except subprocess.TimeoutExpired:
52
+ return None, "CBM index timeout"
53
+ except Exception as e:
54
+ return None, "CBM error: %s" % str(e)
55
+
56
+ _EXT_LANG = {
57
+ ".py": "Python", ".js": "JavaScript", ".ts": "TypeScript", ".tsx": "TSX",
58
+ ".go": "Go", ".java": "Java", ".kt": "Kotlin", ".scala": "Scala",
59
+ ".rs": "Rust", ".c": "C", ".h": "C/C++", ".cpp": "C++", ".cc": "C++",
60
+ ".hpp": "C++", ".cxx": "C++", ".cs": "C#", ".php": "PHP", ".rb": "Ruby",
61
+ ".swift": "Swift", ".dart": "Dart", ".lua": "Lua", ".sh": "Bash",
62
+ ".bash": "Bash", ".zsh": "Bash", ".pl": "Perl", ".r": "R",
63
+ ".ex": "Elixir", ".exs": "Elixir", ".erl": "Erlang", ".hs": "Haskell",
64
+ ".clj": "Clojure", ".cljs": "Clojure", ".cljc": "Clojure",
65
+ ".el": "Emacs Lisp", ".scm": "Scheme", ".lisp": "Common Lisp",
66
+ ".jl": "Julia", ".nim": "Nim", ".cr": "Crystal", ".d": "D",
67
+ ".gradle": "Gradle", ".groovy": "Groovy", ".yaml": "YAML", ".yml": "YAML",
68
+ ".json": "JSON", ".toml": "TOML", ".xml": "XML", ".sql": "SQL",
69
+ ".html": "HTML", ".css": "CSS", ".scss": "SCSS", ".less": "LESS",
70
+ ".dockerfile": "Dockerfile", ".tf": "Terraform", ".hcl": "HCL",
71
+ ".vue": "Vue", ".svelte": "Svelte", ".astro": "Astro",
72
+ ".md": "Markdown", ".rst": "reStructuredText",
73
+ ".vim": "Vim Script", ".nix": "Nix", ".zig": "Zig",
74
+ ".elm": "Elm", ".purs": "PureScript", ".f90": "Fortran",
75
+ ".f": "Fortran", ".cbl": "COBOL", ".pas": "Pascal",
76
+ ".proto": "Protobuf", ".thrift": "Thrift",
77
+ ".cu": "CUDA", ".glsl": "GLSL", ".v": "Verilog", ".sv": "SystemVerilog",
78
+ ".sol": "Solidity", ".move": "Move", ".gleam": "Gleam",
79
+ }
80
+ _SPECIAL_FILENAMES = {
81
+ "Dockerfile": "Dockerfile", "Makefile": "Makefile", "CMakeLists.txt": "CMake",
82
+ "go.mod": "Go Module", "go.sum": "Go Module", "BUILD": "Starlark",
83
+ "WORKSPACE": "Starlark", ".env": "DotEnv", ".gitignore": "Git Ignore",
84
+ "requirements.txt": "Python Requirements", "package.json": "NPM Manifest",
85
+ "tsconfig.json": "TS Config", "Cargo.toml": "Cargo Manifest",
86
+ "pyproject.toml": "Python Project",
87
+ }
88
+ MIN_INDEGREE = 3
89
+
90
+ def _detect_language(fp):
91
+ b = os.path.basename(fp)
92
+ if b in _SPECIAL_FILENAMES:
93
+ return _SPECIAL_FILENAMES[b]
94
+ if b.startswith(".env."):
95
+ return "DotEnv"
96
+ return _EXT_LANG.get(os.path.splitext(b)[1])
97
+
98
+ def _qn_to_package(qn):
99
+ if not qn: return ""
100
+ parts = qn.split(".")
101
+ if len(parts) >= 4: return parts[2][:256]
102
+ if len(parts) >= 2: return parts[1][:256]
103
+ return ""
104
+
105
+ def _qn_to_module(qn):
106
+ if not qn: return ""
107
+ parts = qn.split(".")
108
+ return parts[1] if len(parts) >= 2 else ""
109
+
110
+ def _classify_layer(fi, fo, has_routes, has_entry):
111
+ if has_entry and fo > 0 and fi == 0: return ("entry", "has entry points, only outbound")
112
+ if has_routes: return ("api", "has HTTP routes")
113
+ if fi > fo and fi > MIN_INDEGREE: return ("core", "high fan-in (%d in, %d out)" % (fi, fo))
114
+ if fo == 0 and fi > 0: return ("leaf", "only inbound, no outbound")
115
+ if fi == 0 and fo > 0: return ("entry", "only outbound calls")
116
+ return ("internal", "fan-in=%d, fan-out=%d" % (fi, fo))
117
+
118
+ def _find_db(project):
119
+ if os.path.isfile(project): return project, None
120
+ name = project if project.endswith(".db") else project + ".db"
121
+ path = os.path.join(CACHE_DIR, name)
122
+ if os.path.isfile(path): return path, None
123
+ hits = [f for f in os.listdir(CACHE_DIR) if f.endswith(".db") and project in f]
124
+ if len(hits) == 1: return os.path.join(CACHE_DIR, hits[0]), None
125
+ if len(hits) > 1: return None, "multiple matches: %s" % hits
126
+ return None, "DB not found: %s" % project
127
+
128
+ def _list_projects():
129
+ dbs = sorted(f for f in os.listdir(CACHE_DIR) if f.endswith(".db") and f != "_config.db")
130
+ if not dbs: return "No .db files in %s" % CACHE_DIR
131
+ return "Available projects:\n" + "\n".join(
132
+ " %s (%.1f MB)" % (d[:-3], os.path.getsize(os.path.join(CACHE_DIR, d))/1e6)
133
+ for d in dbs)
134
+
135
+ def _extract_call_chains(conn, max_chains=8, max_depth=5):
136
+ entries = conn.execute(
137
+ "SELECT id,name FROM nodes WHERE json_extract(properties,'$.is_entry_point')=1 "
138
+ "AND (json_extract(properties,'$.is_test') IS NULL OR json_extract(properties,'$.is_test')!=1) "
139
+ "AND file_path NOT LIKE '%test%' LIMIT 15").fetchall()
140
+ if not entries:
141
+ entries = conn.execute(
142
+ "SELECT n.id,n.name FROM nodes n JOIN edges e ON e.source_id=n.id AND e.type='CALLS' "
143
+ "WHERE n.label IN ('Function','Method') AND (json_extract(n.properties,'$.is_test') IS NULL "
144
+ "OR json_extract(n.properties,'$.is_test')!=1) AND n.file_path NOT LIKE '%test%' "
145
+ "GROUP BY n.id ORDER BY COUNT(*) DESC LIMIT 10").fetchall()
146
+ if not entries: return []
147
+
148
+ hotspot_ids = set(r[0] for r in conn.execute(
149
+ "SELECT n.id FROM nodes n JOIN edges e ON e.target_id=n.id AND e.type='CALLS' "
150
+ "WHERE n.label IN ('Function','Method') AND (json_extract(n.properties,'$.is_test') IS NULL "
151
+ "OR json_extract(n.properties,'$.is_test')!=1) GROUP BY n.id HAVING COUNT(*)>=5").fetchall())
152
+
153
+ id_to_name = dict((r[0], r[1]) for r in conn.execute(
154
+ "SELECT id,name FROM nodes WHERE label IN ('Function','Method')").fetchall())
155
+
156
+ callees_of = defaultdict(list); callers_of = defaultdict(list)
157
+ for r in conn.execute("SELECT source_id,target_id FROM edges WHERE type='CALLS'").fetchall():
158
+ callees_of[r[0]].append(r[1]); callers_of[r[1]].append(r[0])
159
+
160
+ all_chains = []; seen = set()
161
+ for eid, ename in entries:
162
+ stack = [(eid, [ename], 0)]
163
+ while stack:
164
+ nid, path, hc = stack.pop()
165
+ if len(path) >= max_depth:
166
+ if len(path) >= 3 and path[-1] not in seen:
167
+ seen.add(path[-1]); all_chains.append((path, hc))
168
+ continue
169
+ cands = [c for c in callees_of.get(nid, []) if id_to_name.get(c, "") not in path]
170
+ cands.sort(key=lambda c: len(callers_of.get(c, [])), reverse=True)
171
+ if not cands:
172
+ if len(path) >= 2 and path[-1] not in seen:
173
+ seen.add(path[-1]); all_chains.append((path, hc))
174
+ continue
175
+ for c in cands[:4]:
176
+ cname = id_to_name.get(c, ""); new_hc = hc + (1 if c in hotspot_ids else 0)
177
+ if cname: stack.append((c, path + [cname], new_hc))
178
+ all_chains.sort(key=lambda x: (x[1], len(x[0])), reverse=True)
179
+ return [c for c, _ in all_chains[:max_chains]]
180
+
181
+ def _extract_data_flow(conn):
182
+ sections = []
183
+ cfg_rows = conn.execute(
184
+ "SELECT n1.name,n2.name FROM edges e JOIN nodes n1 ON e.source_id=n1.id "
185
+ "JOIN nodes n2 ON e.target_id=n2.id WHERE e.type='CONFIGURES' ORDER BY e.id LIMIT 20").fetchall()
186
+ if cfg_rows:
187
+ env_rdrs = defaultdict(list)
188
+ for fn,env in cfg_rows: env_rdrs[env].append(fn)
189
+ cfg_lines = ["%s -> %s" % (e, ", ".join(r[:5])) for e, r in sorted(env_rdrs.items())]
190
+ sections.append(("config", cfg_lines[:8]))
191
+
192
+ write_rows = conn.execute(
193
+ "SELECT e.target_id,COUNT(*),n2.name FROM edges e JOIN nodes n2 ON e.target_id=n2.id "
194
+ "WHERE e.type='WRITES' GROUP BY e.target_id HAVING COUNT(*)>=2 ORDER BY COUNT(*) DESC LIMIT 10").fetchall()
195
+ write_lines = []
196
+ for tid,cnt,vname in write_rows:
197
+ writers = [r[0] for r in conn.execute(
198
+ "SELECT n1.name FROM edges e JOIN nodes n1 ON e.source_id=n1.id "
199
+ "WHERE e.type='WRITES' AND e.target_id=? LIMIT 5", (tid,)).fetchall()]
200
+ readers = [r[0] for r in conn.execute(
201
+ "SELECT n1.name FROM edges e JOIN nodes n1 ON e.source_id=n1.id "
202
+ "WHERE e.type='USAGE' AND e.target_id=? LIMIT 5", (tid,)).fetchall()]
203
+ if writers and readers:
204
+ write_lines.append("%s: w<-%s, r->%s" % (vname, ",".join(writers), ",".join(readers)))
205
+ if write_lines: sections.append(("state vars", write_lines[:5]))
206
+
207
+ grpc_rows = conn.execute(
208
+ "SELECT n1.name,n2.name,json_extract(e.properties,'$.confidence') FROM edges e "
209
+ "JOIN nodes n1 ON e.source_id=n1.id JOIN nodes n2 ON e.target_id=n2.id "
210
+ "WHERE e.type='GRPC_CALLS' AND (json_extract(e.properties,'$.confidence') IS NULL "
211
+ "OR CAST(json_extract(e.properties,'$.confidence') AS REAL)>=0.8) ORDER BY e.id LIMIT 10").fetchall()
212
+ if grpc_rows:
213
+ grpc_lines = ["%s -> %s (gRPC)" % (c, t.split("/")[-1] if "/" in t else t) for c,t,_ in grpc_rows]
214
+ sections.append(("cross-service", grpc_lines))
215
+ return sections
216
+
217
+ def _run_skeleton(db_path, max_tokens=3000, include_data_flow=False):
218
+ uri = "file:%s?immutable=1" % db_path
219
+ conn = sqlite3.connect(uri, uri=True)
220
+ conn.row_factory = sqlite3.Row; lines = []
221
+
222
+ row = conn.execute("SELECT name,root_path,indexed_at FROM projects LIMIT 1").fetchone()
223
+ if not row: return "DB empty"
224
+ project = row["name"]
225
+ lines.extend(["project: %s" % project, "root: %s" % row["root_path"],
226
+ "indexed: %s" % row["indexed_at"],
227
+ "nodes: %d, edges: %d" % (conn.execute("SELECT COUNT(*) FROM nodes").fetchone()[0],
228
+ conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]), ""])
229
+
230
+ # languages
231
+ counts = Counter()
232
+ for r in conn.execute("SELECT file_path FROM nodes WHERE label='File'").fetchall():
233
+ l = _detect_language(r[0])
234
+ if l: counts[l] += 1
235
+ if counts:
236
+ ls = ", ".join("%s %d" % x for x in counts.most_common(5))
237
+ lines.append("languages: %s\n" % ls)
238
+
239
+ # entry points
240
+ rows = conn.execute(
241
+ "SELECT name,file_path FROM nodes WHERE json_extract(properties,'$.is_entry_point')=1 "
242
+ "AND (json_extract(properties,'$.is_test') IS NULL OR json_extract(properties,'$.is_test')!=1) "
243
+ "AND file_path NOT LIKE '%test%' LIMIT 20").fetchall()
244
+ if rows:
245
+ lines.append("entry points:")
246
+ for nm, fp in rows[:8]: lines.append(" %s:%s" % (fp, nm))
247
+ if len(rows) > 8: lines.append(" ... (%d total)" % len(rows))
248
+ lines.append("")
249
+
250
+ # routes
251
+ rows = conn.execute("SELECT name,properties FROM nodes WHERE label='Route' LIMIT 20").fetchall()
252
+ if rows:
253
+ rts = []
254
+ for r in rows:
255
+ props = json.loads(r[1] or "{}"); path = r[0]
256
+ if "/" not in path or len(path) > 60:
257
+ path = path.rsplit("/", 1)[-1]
258
+ if len(path) > 60: path = "..." + path[-57:]
259
+ rts.append("%s %s" % (props.get("method", "ANY"), path))
260
+ lines.append("routes: %s\n" % ", ".join(rts[:10]))
261
+
262
+ # hotspots
263
+ rows = conn.execute(
264
+ "SELECT n.name,n.file_path,COUNT(*) FROM nodes n JOIN edges e ON e.target_id=n.id AND e.type='CALLS' "
265
+ "WHERE n.label IN ('Function','Method') AND (json_extract(n.properties,'$.is_test') IS NULL "
266
+ "OR json_extract(n.properties,'$.is_test')!=1) AND n.file_path NOT LIKE '%test%' "
267
+ "GROUP BY n.id ORDER BY COUNT(*) DESC LIMIT 10").fetchall()
268
+ if rows:
269
+ lines.append("hotspots:")
270
+ for nm, fp, cnt in rows: lines.append(" %s (%d in) -- %s" % (nm, cnt, fp))
271
+ lines.append("")
272
+
273
+ # boundaries
274
+ id_to_pkg = {}
275
+ for r in conn.execute("SELECT id,qualified_name,file_path FROM nodes "
276
+ "WHERE label IN ('Function','Method','Class')").fetchall():
277
+ pkg = _qn_to_package(r[1]) or os.path.dirname(r[2] or "").replace("/", ".") or "(root)"
278
+ id_to_pkg[r[0]] = pkg
279
+ cross = Counter()
280
+ for r in conn.execute("SELECT source_id,target_id FROM edges WHERE type='CALLS'").fetchall():
281
+ s = id_to_pkg.get(r[0], ""); t = id_to_pkg.get(r[1], "")
282
+ if s and t and s != t: cross[(s, t)] += 1
283
+ if cross:
284
+ lines.append("cross-pkg calls (top):")
285
+ for (s, t), c in cross.most_common(5): lines.append(" %s->%s (%d)" % (s, t, c))
286
+ lines.append("")
287
+
288
+ # layers
289
+ if cross:
290
+ fi_d = defaultdict(int); fo_d = defaultdict(int); pkgs = set()
291
+ for (s, t), c in cross.items(): fo_d[s] += c; fi_d[t] += c; pkgs.add(s); pkgs.add(t)
292
+ rp = {_qn_to_package(r[0]) for r in conn.execute(
293
+ "SELECT qualified_name FROM nodes WHERE label='Route'").fetchall()}
294
+ ep = {_qn_to_package(r[0]) for r in conn.execute(
295
+ "SELECT qualified_name FROM nodes WHERE json_extract(properties,'$.is_entry_point')=1").fetchall()}
296
+ by_l = defaultdict(list)
297
+ for p in sorted(pkgs):
298
+ fi = fi_d[p]; fo = fo_d[p]; ly, _ = _classify_layer(fi, fo, p in rp, p in ep)
299
+ by_l[ly].append("%s(%d in)" % (p, fi) if fi else p)
300
+ if by_l:
301
+ lines.append("layers:")
302
+ for ln in ("core", "api", "entry", "leaf", "internal"):
303
+ if ln not in by_l: continue
304
+ shown = by_l[ln][:8]; lines.append(" %s: %s" % (ln, ", ".join(shown)))
305
+ if len(by_l[ln]) > 8: lines.append(" ... (%d total)" % len(by_l[ln]))
306
+ lines.append("")
307
+
308
+ # packages
309
+ pkg_c = Counter()
310
+ for r in conn.execute("SELECT qualified_name FROM nodes "
311
+ "WHERE label IN ('Function','Method','Class','Variable')").fetchall():
312
+ mod = _qn_to_module(r[0])
313
+ if mod: pkg_c[mod] += 1
314
+ if pkg_c:
315
+ lines.append("top-level modules: %s\n" % ", ".join("%s(%d)" % x for x in pkg_c.most_common(8)))
316
+
317
+ # top dirs
318
+ tds = set()
319
+ for r in conn.execute("SELECT file_path FROM nodes WHERE label='File'").fetchall():
320
+ if r[0]: tds.add(r[0].split("/")[0])
321
+ if tds:
322
+ lines.append("top dirs: %s\n" % ", ".join(sorted(tds)[:15]))
323
+
324
+ # clusters (simplified)
325
+ rows = conn.execute("SELECT id,qualified_name FROM nodes "
326
+ "WHERE label IN ('Function','Method','Class')").fetchall()
327
+ if len(rows) >= 20:
328
+ id2m = {}; mm = defaultdict(list)
329
+ for r in rows: m = _qn_to_module(r[1]) or "(root)"; id2m[r[0]] = m; mm[m].append(r[0])
330
+ intra = defaultdict(int); tot = defaultdict(int)
331
+ for r in conn.execute("SELECT source_id,target_id FROM edges WHERE type='CALLS'").fetchall():
332
+ sm = id2m.get(r[0]); tm = id2m.get(r[1])
333
+ if sm and tm: tot[sm] += 1; intra[sm] = intra.get(sm, 0) + 1 if sm == tm else intra.get(sm, 0)
334
+ cls = []
335
+ for i, (m, mems) in enumerate(sorted(mm.items(), key=lambda x: -len(x[1]))):
336
+ if len(mems) < 5: continue
337
+ cls.append((i+1, m, len(mems), intra[m] / max(tot[m], 1)))
338
+ if len(cls) >= 5: break
339
+ if cls:
340
+ lines.append("clusters:")
341
+ for c1, c2, c3, c4 in cls:
342
+ lines.append(" C%d: %s (%d, cohesion %.2f)" % (c1, c2, c3, c4))
343
+ lines.append("")
344
+
345
+ # complexity
346
+ rows = conn.execute(
347
+ "SELECT name,file_path,properties FROM nodes WHERE label IN ('Function','Method') "
348
+ "AND (json_extract(properties,'$.is_test') IS NULL OR json_extract(properties,'$.is_test')!=1) "
349
+ "ORDER BY json_extract(properties,'$.transitive_loop_depth') DESC LIMIT 20").fetchall()
350
+ cx = []
351
+ for r in rows:
352
+ p = json.loads(r[2] or "{}"); tld = p.get("transitive_loop_depth", 0)
353
+ lsl = p.get("linear_scan_in_loop", 0); rec = p.get("recursive", False)
354
+ if tld >= 2 or lsl > 0 or rec: cx.append((r[0], r[1], tld, lsl, rec))
355
+ if cx:
356
+ lines.append("complexity:")
357
+ for name, fp, tld, lsl, rec in cx[:5]:
358
+ desc = []; tld >= 2 and desc.append("O(n^%d)" % tld)
359
+ lsl > 0 and desc.append("scan-in-loop x%d" % lsl); rec and desc.append("recursive")
360
+ lines.append(" %s -- %s (%s)" % (name, ", ".join(desc), fp))
361
+ lines.append("")
362
+
363
+ # call chains
364
+ chains = _extract_call_chains(conn)
365
+ if chains:
366
+ lines.append("call chains:")
367
+ for c in chains: lines.append(" " + " -> ".join(c))
368
+ lines.append("")
369
+
370
+ # data flow
371
+ if include_data_flow:
372
+ df = _extract_data_flow(conn)
373
+ if df:
374
+ lines.append("data flow:")
375
+ for title, slines in df:
376
+ lines.append(" [%s]" % title)
377
+ for l in slines: lines.append(" %s" % l)
378
+ lines.append("")
379
+
380
+ conn.close()
381
+ out = "\n".join(lines); max_chars = max_tokens * 4
382
+ return out[:max_chars] + "\n... (truncated)" if len(out) > max_chars else out
383
+
384
+ CBM_SKELETON_SCHEMA = {
385
+ "name": "cancanneed",
386
+ "description": "Extract compressed skeleton (~2K tokens) from codebase graph DB.",
387
+ "parameters": {
388
+ "type": "object",
389
+ "properties": {
390
+ "repo_path": {"type": "string", "description": "Absolute path to source directory"},
391
+ "project": {"type": "string", "description": "Project name or .db path"},
392
+ "max_tokens": {"type": "integer", "description": "Token cap. Default 3000.", "default": 3000},
393
+ "include_data_flow": {"type": "boolean", "description": "Include data flow chains.", "default": False},
394
+ },
395
+ },
396
+ }
397
+
398
+ def run(repo_path=None, project=None, max_tokens=3000, include_data_flow=False):
399
+ if repo_path:
400
+ db, err = _ensure_indexed(repo_path)
401
+ return (None, err) if err else (_run_skeleton(db, max_tokens, include_data_flow), None)
402
+ if project:
403
+ db, err = _find_db(project)
404
+ return (None, err) if err else (_run_skeleton(db, max_tokens, include_data_flow), None)
405
+ return None, _list_projects()
406
+
407
+ def _skill_md():
408
+ try: return open(os.path.join(os.path.dirname(__file__), "skills", "SKILL.md")).read()
409
+ except Exception: return "# cancanneed\n\nSKILL.md not found."
410
+
411
+ def main():
412
+ args = [a for a in sys.argv[1:] if a not in ("--json", "--skill")]
413
+ json_out = "--json" in sys.argv; skill_out = "--skill" in sys.argv
414
+ if skill_out: print(_skill_md()); return
415
+ arg = args[0] if args else ""
416
+ rp = pr = None
417
+ if arg and (arg.startswith("/") or arg.startswith("~") or arg.startswith(".")): rp = os.path.expanduser(arg)
418
+ elif arg: pr = arg
419
+ skel, err = run(repo_path=rp, project=pr)
420
+ if json_out:
421
+ print(json.dumps({"error": err} if err else {"skeleton": skel}, ensure_ascii=False))
422
+ else:
423
+ if err: print(err, file=sys.stderr); sys.exit(1)
424
+ else: print(skel)
425
+
426
+ if __name__ == "__main__":
427
+ main()
package/cancanneed.sh ADDED
@@ -0,0 +1,5 @@
1
+ #!/bin/sh
2
+ # cancanneed npx wrapper — delegates to python3 cancanneed.py
3
+ DIR=$(cd "$(dirname "$0")" && pwd)
4
+ PY=${PYTHON:-python3}
5
+ exec "$PY" "$DIR/cancanneed.py" "$@"
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "cancanneed",
3
+ "version": "0.1.0",
4
+ "description": "代码仓库压缩骨架提取器 — ~2K tokens 看清项目全貌",
5
+ "bin": {
6
+ "cancanneed": "./cancanneed.sh"
7
+ },
8
+ "files": [
9
+ "cancanneed.py",
10
+ "cancanneed.sh",
11
+ "skills/SKILL.md",
12
+ "README.md"
13
+ ],
14
+ "keywords": ["codebase", "skeleton", "architecture", "agent", "cbm"],
15
+ "license": "MIT"
16
+ }
@@ -0,0 +1,62 @@
1
+ ---
2
+ name: cancanneed
3
+ description: 从代码仓库提取压缩骨架供 agent 上下文注入。调用 cancanneed 工具一步获得项目全貌。
4
+ ---
5
+
6
+ # cancanneed
7
+
8
+ Agent 接手新项目时,调用 `cancanneed` 一次性拿到项目骨架,
9
+ 不必反复 `search_graph` → `trace_path` 试错。
10
+
11
+ ## 何时使用
12
+
13
+ - Agent 首次进入一个新代码仓库
14
+ - 需要快速了解项目模块结构、依赖关系
15
+ - 评估修改某个函数的影响面前,需要全局视野
16
+ - 任何需要"这个项目长什么样"的时刻
17
+
18
+ ## 用法
19
+
20
+ 两个模式:
21
+
22
+ **repo_path** — 传源码目录,自动索引后提取(首次 10-60s,后续秒级):
23
+
24
+ ```
25
+ cancanneed(repo_path="/path/to/project")
26
+ ```
27
+
28
+ **project** — 传已索引的项目名或 .db 路径,直接读 DB:
29
+
30
+ ```
31
+ cancanneed(project="ai-runtime-trace")
32
+ ```
33
+
34
+ 不传参列出所有可用项目:
35
+
36
+ ```
37
+ cancanneed()
38
+ ```
39
+
40
+ 可选参数:`max_tokens`(默认 3000),`include_data_flow`(默认 false,开启后追加数据透传链路)。
41
+
42
+ ## 会收到什么
43
+
44
+ 约 ~2K tokens 的压缩骨架文本,包含:
45
+
46
+ - 语言分布(文件数统计)
47
+ - 入口函数(is_entry_point)
48
+ - HTTP 路由
49
+ - 高扇入热点 top 10(被调用最多的函数)
50
+ - 跨包调用边界 top 5
51
+ - 分层(core/api/entry/leaf/internal)
52
+ - 模块聚类 + 内聚度
53
+ - 复杂度热点(O(n^k) 风险函数)
54
+ - 主调用链路(从入口函数沿 CALLS 边做 5 跳 DFS)
55
+ - 可选:数据透传链路(配置传播、状态变量、跨服务调用)
56
+
57
+ 拿到骨架后,agent 无需反复试错即可定位到要改的文件和函数。深入细节时再按需查代码。
58
+
59
+ ## 前置条件
60
+
61
+ 项目已用 codebase-memory-mcp 索引(大多数项目由 MCP 自动索引)。
62
+ `repo_path` 模式会自动触发首次索引。