scripts/workspace_inventory.py
7.8 KB| 1 | #!/usr/bin/env python3 |
| 2 | """workspace_inventory.py — Glob+Grep helper for capture's Section 3 (Connections). |
| 3 | |
| 4 | Stdlib-only. Given a working directory + a list of dump-derived keywords, |
| 5 | returns a structured inventory that the capture skill can use to surface |
| 6 | real workspace connections (never fabricated). |
| 7 | |
| 8 | What it returns: |
| 9 | 1. Per-keyword filename matches (Glob-style) |
| 10 | 2. Per-keyword content matches (line-grep across source files) |
| 11 | 3. Top-level folder structure (max-depth 2) |
| 12 | |
| 13 | What it does NOT do: |
| 14 | - Score relevance (capture skill applies judgment on top) |
| 15 | - Fabricate matches (only real Glob/Grep results) |
| 16 | - Make LLM calls |
| 17 | |
| 18 | Usage: |
| 19 | python workspace_inventory.py --root . --keywords "auth,login,2fa" |
| 20 | python workspace_inventory.py --root . --keywords "skill,megaprompt" --output json |
| 21 | python workspace_inventory.py --sample |
| 22 | """ |
| 23 | |
| 24 | import argparse |
| 25 | import json |
| 26 | import re |
| 27 | import sys |
| 28 | from pathlib import Path |
| 29 | from typing import Any, Dict, List, Set |
| 30 | |
| 31 | |
| 32 | DEFAULT_SOURCE_EXTENSIONS = { |
| 33 | ".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".java", ".kt", ".rb", |
| 34 | ".cs", ".rs", ".swift", ".php", ".scala", ".clj", ".ex", ".exs", |
| 35 | ".md", ".mdx", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml", |
| 36 | } |
| 37 | DEFAULT_EXCLUDE_DIRS = { |
| 38 | "node_modules", ".git", "dist", "build", "target", |
| 39 | ".venv", "venv", "__pycache__", ".next", ".cache", |
| 40 | } |
| 41 | MAX_FILENAME_MATCHES_PER_KEYWORD = 20 |
| 42 | MAX_CONTENT_MATCHES_PER_KEYWORD = 10 |
| 43 | MAX_FOLDER_DEPTH = 2 |
| 44 | |
| 45 | |
| 46 | SAMPLE_TREE: Dict[str, str] = { |
| 47 | "src/auth/login.ts": "// auth login flow handler\nexport function login() {}\n", |
| 48 | "src/auth/2fa.ts": "// 2fa stub\nexport function setup2FA() {}\n", |
| 49 | "src/users/profile.ts": "// user profile\nexport function getProfile() {}\n", |
| 50 | "docs/auth-bugs.md": "# Auth bugs\n\n- The login race condition is back.\n", |
| 51 | "tests/auth.test.ts": "// auth tests\ndescribe('auth', () => {});\n", |
| 52 | "README.md": "# project\n\nAuth + login + users.\n", |
| 53 | } |
| 54 | |
| 55 | |
| 56 | def collect_files(root: Path, source_extensions: Set[str], exclude_dirs: Set[str]) -> List[Path]: |
| 57 | found: List[Path] = [] |
| 58 | for p in root.rglob("*"): |
| 59 | if p.is_dir(): |
| 60 | continue |
| 61 | if any(part in exclude_dirs for part in p.parts): |
| 62 | continue |
| 63 | if p.suffix.lower() in source_extensions: |
| 64 | found.append(p) |
| 65 | return found |
| 66 | |
| 67 | |
| 68 | def filename_matches(files: List[Path], keyword: str) -> List[str]: |
| 69 | kw = keyword.lower() |
| 70 | out: List[str] = [] |
| 71 | for p in files: |
| 72 | if kw in p.name.lower(): |
| 73 | out.append(str(p)) |
| 74 | if len(out) >= MAX_FILENAME_MATCHES_PER_KEYWORD: |
| 75 | break |
| 76 | return out |
| 77 | |
| 78 | |
| 79 | def content_matches(files: List[Path], keyword: str) -> List[Dict[str, Any]]: |
| 80 | pattern = re.compile(re.escape(keyword), re.IGNORECASE) |
| 81 | out: List[Dict[str, Any]] = [] |
| 82 | for p in files: |
| 83 | try: |
| 84 | text = p.read_text(encoding="utf-8", errors="ignore") |
| 85 | except OSError: |
| 86 | continue |
| 87 | for line_no, line in enumerate(text.splitlines(), start=1): |
| 88 | if pattern.search(line): |
| 89 | out.append({ |
| 90 | "file": str(p), |
| 91 | "line": line_no, |
| 92 | "snippet": line.strip()[:120], |
| 93 | }) |
| 94 | if len(out) >= MAX_CONTENT_MATCHES_PER_KEYWORD: |
| 95 | return out |
| 96 | return out |
| 97 | |
| 98 | |
| 99 | def folder_structure(root: Path, max_depth: int, exclude_dirs: Set[str]) -> List[str]: |
| 100 | out: List[str] = [] |
| 101 | root = root.resolve() |
| 102 | for p in root.rglob("*"): |
| 103 | if not p.is_dir(): |
| 104 | continue |
| 105 | if any(part in exclude_dirs for part in p.parts): |
| 106 | continue |
| 107 | try: |
| 108 | rel = p.relative_to(root) |
| 109 | except ValueError: |
| 110 | continue |
| 111 | depth = len(rel.parts) |
| 112 | if 0 < depth <= max_depth: |
| 113 | out.append(str(rel)) |
| 114 | return sorted(out) |
| 115 | |
| 116 | |
| 117 | def inventory( |
| 118 | root: Path, |
| 119 | keywords: List[str], |
| 120 | source_extensions: Set[str], |
| 121 | exclude_dirs: Set[str], |
| 122 | ) -> Dict[str, Any]: |
| 123 | files = collect_files(root, source_extensions, exclude_dirs) |
| 124 | per_keyword: Dict[str, Dict[str, Any]] = {} |
| 125 | for kw in keywords: |
| 126 | per_keyword[kw] = { |
| 127 | "filename_matches": filename_matches(files, kw), |
| 128 | "content_matches": content_matches(files, kw), |
| 129 | } |
| 130 | return { |
| 131 | "root": str(root.resolve()), |
| 132 | "files_scanned": len(files), |
| 133 | "folder_structure": folder_structure(root, MAX_FOLDER_DEPTH, exclude_dirs), |
| 134 | "per_keyword": per_keyword, |
| 135 | } |
| 136 | |
| 137 | |
| 138 | def render_human(result: Dict[str, Any]) -> str: |
| 139 | out: List[str] = [] |
| 140 | out.append(f"Workspace inventory for: {result['root']}") |
| 141 | out.append(f" Files scanned: {result['files_scanned']}") |
| 142 | out.append("") |
| 143 | out.append("Folder structure (max depth 2):") |
| 144 | for f in result["folder_structure"][:30]: |
| 145 | out.append(f" - {f}/") |
| 146 | if len(result["folder_structure"]) > 30: |
| 147 | out.append(f" ... + {len(result['folder_structure']) - 30} more") |
| 148 | out.append("") |
| 149 | for kw, hits in result["per_keyword"].items(): |
| 150 | out.append(f"Keyword: '{kw}'") |
| 151 | if hits["filename_matches"]: |
| 152 | out.append(f" Filename matches ({len(hits['filename_matches'])}):") |
| 153 | for f in hits["filename_matches"]: |
| 154 | out.append(f" - {f}") |
| 155 | else: |
| 156 | out.append(" Filename matches: (none)") |
| 157 | if hits["content_matches"]: |
| 158 | out.append(f" Content matches ({len(hits['content_matches'])}):") |
| 159 | for m in hits["content_matches"]: |
| 160 | out.append(f" - {m['file']}:{m['line']} {m['snippet']}") |
| 161 | else: |
| 162 | out.append(" Content matches: (none)") |
| 163 | out.append("") |
| 164 | return "\n".join(out) |
| 165 | |
| 166 | |
| 167 | def run_sample(keywords: List[str]) -> Dict[str, Any]: |
| 168 | import tempfile |
| 169 | with tempfile.TemporaryDirectory() as td: |
| 170 | root = Path(td) |
| 171 | for rel, content in SAMPLE_TREE.items(): |
| 172 | p = root / rel |
| 173 | p.parent.mkdir(parents=True, exist_ok=True) |
| 174 | p.write_text(content, encoding="utf-8") |
| 175 | return inventory(root, keywords, DEFAULT_SOURCE_EXTENSIONS, DEFAULT_EXCLUDE_DIRS) |
| 176 | |
| 177 | |
| 178 | def main(argv: List[str]) -> int: |
| 179 | parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) |
| 180 | parser.add_argument("--root", help="Root directory to inventory") |
| 181 | parser.add_argument("--keywords", help="Comma-separated keywords to search for") |
| 182 | parser.add_argument("--extensions", help="Comma-separated source extensions (default: common)") |
| 183 | parser.add_argument("--sample", action="store_true", help="Inventory the embedded sample tree") |
| 184 | parser.add_argument("--output", choices=["human", "json"], default="human") |
| 185 | args = parser.parse_args(argv) |
| 186 | |
| 187 | if args.sample: |
| 188 | sample_keywords = ["auth", "login", "2fa"] if not args.keywords else [k.strip() for k in args.keywords.split(",") if k.strip()] |
| 189 | result = run_sample(sample_keywords) |
| 190 | elif args.root and args.keywords: |
| 191 | root = Path(args.root) |
| 192 | if not root.exists(): |
| 193 | print(f"error: {args.root} not found", file=sys.stderr) |
| 194 | return 2 |
| 195 | kws = [k.strip() for k in args.keywords.split(",") if k.strip()] |
| 196 | if not kws: |
| 197 | print("error: --keywords must list at least one keyword", file=sys.stderr) |
| 198 | return 2 |
| 199 | if args.extensions: |
| 200 | exts = {e.strip() if e.strip().startswith(".") else "." + e.strip() for e in args.extensions.split(",")} |
| 201 | else: |
| 202 | exts = DEFAULT_SOURCE_EXTENSIONS |
| 203 | result = inventory(root, kws, exts, DEFAULT_EXCLUDE_DIRS) |
| 204 | else: |
| 205 | parser.print_help() |
| 206 | return 0 |
| 207 | |
| 208 | if args.output == "json": |
| 209 | print(json.dumps(result, indent=2)) |
| 210 | else: |
| 211 | print(render_human(result)) |
| 212 | return 0 |
| 213 | |
| 214 | |
| 215 | if __name__ == "__main__": |
| 216 | sys.exit(main(sys.argv[1:])) |
文件内容是平台检查时读出的文本,只供查看。平台不会运行包里的任何脚本或程序。

