agent-wiki-cli 0.3.28__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.
Files changed (47) hide show
  1. agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
  2. agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
  3. agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
  4. agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
  5. agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
  6. agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
  7. llm_wiki_cli/__init__.py +7 -0
  8. llm_wiki_cli/cli.py +231 -0
  9. llm_wiki_cli/commands/__init__.py +1 -0
  10. llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
  11. llm_wiki_cli/commands/bump_cmd.py +55 -0
  12. llm_wiki_cli/commands/context_cmd.py +427 -0
  13. llm_wiki_cli/commands/extract_cmd.py +745 -0
  14. llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
  15. llm_wiki_cli/commands/hook_cmd.py +161 -0
  16. llm_wiki_cli/commands/init_cmd.py +92 -0
  17. llm_wiki_cli/commands/lint_cmd.py +294 -0
  18. llm_wiki_cli/commands/migrate_cmd.py +892 -0
  19. llm_wiki_cli/commands/release_cmd.py +163 -0
  20. llm_wiki_cli/commands/status_cmd.py +70 -0
  21. llm_wiki_cli/commands/sync_cmd.py +521 -0
  22. llm_wiki_cli/commands/trigger_cmd.py +205 -0
  23. llm_wiki_cli/commands/uninstall_cmd.py +221 -0
  24. llm_wiki_cli/commands/upgrade_cmd.py +196 -0
  25. llm_wiki_cli/config.py +318 -0
  26. llm_wiki_cli/extractors/__init__.py +46 -0
  27. llm_wiki_cli/extractors/common.py +90 -0
  28. llm_wiki_cli/extractors/go_extractor.py +143 -0
  29. llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
  30. llm_wiki_cli/extractors/go_scripts/main.go +668 -0
  31. llm_wiki_cli/extractors/python_extractor.py +346 -0
  32. llm_wiki_cli/extractors/rust_extractor.py +143 -0
  33. llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
  34. llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
  35. llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
  36. llm_wiki_cli/extractors/ts_extractor.py +206 -0
  37. llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
  38. llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
  39. llm_wiki_cli/services/__init__.py +0 -0
  40. llm_wiki_cli/services/circuit_breaker.py +79 -0
  41. llm_wiki_cli/services/io.py +47 -0
  42. llm_wiki_cli/services/lockfile.py +60 -0
  43. llm_wiki_cli/services/packages.py +173 -0
  44. llm_wiki_cli/services/paths.py +31 -0
  45. llm_wiki_cli/services/schema.py +214 -0
  46. llm_wiki_cli/services/secure_file.py +22 -0
  47. llm_wiki_cli/services/versioning.py +193 -0
@@ -0,0 +1,55 @@
1
+ import subprocess
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ from ..services.versioning import (
6
+ find_version_file,
7
+ read_version,
8
+ write_version,
9
+ bump_patch,
10
+ bump_minor,
11
+ )
12
+
13
+
14
+ def run(args):
15
+ root = getattr(args, "root", ".")
16
+ version_file = find_version_file(root)
17
+
18
+ if version_file is None:
19
+ print("Error: No version file found (pyproject.toml, setup.cfg, package.json, VERSION).")
20
+ sys.exit(1)
21
+
22
+ current = read_version(version_file)
23
+ if current is None:
24
+ print(f"Error: Could not parse version from {version_file}")
25
+ sys.exit(1)
26
+
27
+ if args.bump_type == "patch":
28
+ new_version = bump_patch(current)
29
+ elif args.bump_type == "minor":
30
+ new_version = bump_minor(current)
31
+ else:
32
+ print(f"Error: Unknown bump type '{args.bump_type}'")
33
+ sys.exit(1)
34
+
35
+ write_version(version_file, new_version)
36
+ print(f"{current} -> {new_version} ({version_file})")
37
+
38
+ # If --stage is set, git-add the version file so it's included in the current commit
39
+ if getattr(args, "stage", False):
40
+ try:
41
+ subprocess.run(
42
+ ["git", "add", str(version_file)],
43
+ check=True,
44
+ capture_output=True,
45
+ text=True,
46
+ )
47
+ except FileNotFoundError:
48
+ print("Error: git not found; could not stage version file.", file=sys.stderr)
49
+ sys.exit(1)
50
+ except subprocess.CalledProcessError as exc:
51
+ detail = (exc.stderr or exc.stdout or "").strip()
52
+ print(f"Error: git add failed for {version_file}.", file=sys.stderr)
53
+ if detail:
54
+ print(detail, file=sys.stderr)
55
+ sys.exit(1)
@@ -0,0 +1,427 @@
1
+ """Structured context budgeting — return priority-ranked, token-budgeted
2
+ codebase context for LLM agents.
3
+
4
+ Priority tiers:
5
+
6
+ - **high**: files changed in the last commit → full deep inventory detail
7
+ - **medium**: 1-hop import neighbors of changed files → slim detail
8
+ - **low**: everything else → names only
9
+
10
+ Usage::
11
+
12
+ llm-wiki context --budget 32000
13
+ llm-wiki context --budget 8000 --format markdown
14
+ llm-wiki context --budget 32000 --focus all
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .extract_cmd import _git_changed_files, get_inventory_result, print_inventory_failures
24
+ from ..config import validate_path
25
+
26
+
27
+ # ── Token estimation ──────────────────────────────────────────────────
28
+
29
+
30
+ def _estimate_tokens(text: str) -> int:
31
+ """Approximate token count using the ~4 chars/token heuristic.
32
+
33
+ No external dependency — good enough for budgeting (within ~10-20%).
34
+ """
35
+ return len(text) // 4
36
+
37
+
38
+ # ── Import graph ──────────────────────────────────────────────────────
39
+
40
+
41
+ def _build_import_graph(inventory: dict) -> dict[str, set[str]]:
42
+ """Build a bidirectional import adjacency map from a deep inventory.
43
+
44
+ For each file A that imports a symbol from file B (both present in *inventory*),
45
+ adds edges A→B and B→A. External / stdlib imports are silently skipped.
46
+
47
+ Returns ``{filepath: {neighbor_filepaths}}`` with every key appearing
48
+ even if it has no neighbors.
49
+ """
50
+ # Build a lookup: dotted module path → inventory filepath
51
+ # e.g. "llm_wiki_cli.config" → "src/llm_wiki_cli/config.py"
52
+ module_to_file: dict[str, str] = {}
53
+ for filepath in inventory:
54
+ # Convert filepath to dotted module path
55
+ mod = _filepath_to_module(filepath)
56
+ if mod:
57
+ module_to_file[mod] = filepath
58
+
59
+ graph: dict[str, set[str]] = {fp: set() for fp in inventory}
60
+
61
+ for filepath, file_data in inventory.items():
62
+ for imp in file_data.get("imports", []):
63
+ imp_module = imp.get("module", "")
64
+ if not imp_module:
65
+ continue
66
+
67
+ # Try exact match, then parent module (for ``from pkg.mod import X``)
68
+ target = module_to_file.get(imp_module)
69
+ if target is None:
70
+ # Try progressively shorter prefixes
71
+ parts = imp_module.split(".")
72
+ for i in range(len(parts) - 1, 0, -1):
73
+ candidate = ".".join(parts[:i])
74
+ target = module_to_file.get(candidate)
75
+ if target is not None:
76
+ break
77
+
78
+ if target is not None and target != filepath:
79
+ graph[filepath].add(target)
80
+ graph[target].add(filepath)
81
+
82
+ return graph
83
+
84
+
85
+ def _filepath_to_module(filepath: str) -> str | None:
86
+ """Convert ``"src/llm_wiki_cli/config.py"`` → ``"llm_wiki_cli.config"``.
87
+
88
+ Strips a leading ``src/`` directory and the ``.py`` suffix, then
89
+ converts path separators to dots. Returns None for non-Python files.
90
+ """
91
+ p = Path(filepath)
92
+ if p.suffix != ".py":
93
+ return None
94
+
95
+ parts = list(p.with_suffix("").parts)
96
+
97
+ # Strip leading "src" directory (common in Python projects)
98
+ if parts and parts[0] == "src":
99
+ parts = parts[1:]
100
+
101
+ # Strip __init__ (package init files map to the package itself)
102
+ if parts and parts[-1] == "__init__":
103
+ parts = parts[:-1]
104
+
105
+ return ".".join(parts) if parts else None
106
+
107
+
108
+ # ── Classification ────────────────────────────────────────────────────
109
+
110
+
111
+ def _classify_files(
112
+ all_files: list[str],
113
+ changed: list[str] | None,
114
+ import_graph: dict[str, set[str]],
115
+ focus: str,
116
+ ) -> dict[str, str]:
117
+ """Assign a priority tier to every file in the inventory.
118
+
119
+ Returns ``{filepath: "high"|"medium"|"low"}``.
120
+ """
121
+ if focus == "all":
122
+ return {fp: "high" for fp in all_files}
123
+
124
+ classification: dict[str, str] = {}
125
+ changed_set = set(changed) if changed else set()
126
+
127
+ # High: changed files
128
+ for fp in all_files:
129
+ if fp in changed_set:
130
+ classification[fp] = "high"
131
+
132
+ # Medium: 1-hop neighbors of changed files
133
+ for fp in changed_set:
134
+ for neighbor in import_graph.get(fp, set()):
135
+ if neighbor not in classification:
136
+ classification[neighbor] = "medium"
137
+
138
+ # Low: everything else
139
+ for fp in all_files:
140
+ if fp not in classification:
141
+ classification[fp] = "low"
142
+
143
+ return classification
144
+
145
+
146
+ # ── Detail-level serializers ──────────────────────────────────────────
147
+
148
+
149
+ def _deep_entry(file_data: dict) -> dict:
150
+ """Full detail: classes with methods, params, docstrings, imports."""
151
+ return {k: v for k, v in file_data.items() if k != "language"}
152
+
153
+
154
+ def _slim_entry(file_data: dict) -> dict:
155
+ """Slim detail: class names/bases/line, function names/lines."""
156
+ return {
157
+ "classes": [
158
+ {"name": c["name"], "bases": c.get("bases", []), "line": c.get("line")}
159
+ for c in file_data.get("classes", [])
160
+ ],
161
+ "functions": [
162
+ {"name": f["name"], "line": f.get("line")}
163
+ for f in file_data.get("functions", [])
164
+ ],
165
+ }
166
+
167
+
168
+ def _summary_entry(file_data: dict) -> dict:
169
+ """Names only: lists of class names and function names."""
170
+ entry: dict[str, list[str]] = {}
171
+ cls_names = [c["name"] for c in file_data.get("classes", [])]
172
+ fn_names = [f["name"] for f in file_data.get("functions", [])]
173
+ if cls_names:
174
+ entry["classes"] = cls_names
175
+ if fn_names:
176
+ entry["functions"] = fn_names
177
+ return entry
178
+
179
+
180
+ _DETAIL_SERIALIZERS = {
181
+ "deep": _deep_entry,
182
+ "slim": _slim_entry,
183
+ "summary": _summary_entry,
184
+ }
185
+
186
+ _PREFERRED_DETAILS = {
187
+ "high": "deep",
188
+ "medium": "slim",
189
+ "low": "summary",
190
+ }
191
+
192
+ _DETAIL_FALLBACKS = {
193
+ "high": ("deep", "slim", "summary"),
194
+ "medium": ("slim", "summary"),
195
+ "low": ("summary",),
196
+ }
197
+
198
+
199
+ # ── Budgeting ─────────────────────────────────────────────────────────
200
+
201
+
202
+ def _build_context_payload(
203
+ inventory: dict,
204
+ classification: dict[str, str],
205
+ budget: int,
206
+ ) -> dict:
207
+ """Build a token-budgeted context payload.
208
+
209
+ Greedy allocation: high-priority files first, then medium, then low.
210
+ Files are downgraded to smaller detail levels before they are omitted.
211
+
212
+ Returns::
213
+
214
+ {
215
+ "budget": <requested>,
216
+ "used": <estimated tokens>,
217
+ "truncated": <whether files were downgraded or omitted>,
218
+ "omitted_files": ["path/too_large.py"],
219
+ "downgraded_files": {"path/file.py": "summary"},
220
+ "files": {
221
+ "path/file.py": {"priority": "high", "detail": "deep", ...detail...},
222
+ ...
223
+ }
224
+ }
225
+ """
226
+ files_out: dict[str, dict] = {}
227
+ omitted_files: list[str] = []
228
+ downgraded_files: dict[str, str] = {}
229
+ used = 0
230
+
231
+ # Process tiers in priority order
232
+ for tier in ("high", "medium", "low"):
233
+ tier_files = sorted(
234
+ fp for fp, pri in classification.items() if pri == tier
235
+ )
236
+ for fp in tier_files:
237
+ file_data = inventory.get(fp, {})
238
+ selected_entry: dict | None = None
239
+ selected_tokens = 0
240
+ selected_detail = ""
241
+
242
+ for detail in _DETAIL_FALLBACKS[tier]:
243
+ entry = _build_entry(file_data, tier, detail)
244
+ entry_tokens = _entry_tokens(fp, entry)
245
+ if used + entry_tokens <= budget:
246
+ selected_entry = entry
247
+ selected_tokens = entry_tokens
248
+ selected_detail = detail
249
+ break
250
+
251
+ if selected_entry is None:
252
+ omitted_files.append(fp)
253
+ continue
254
+
255
+ files_out[fp] = selected_entry
256
+ used += selected_tokens
257
+ if selected_detail != _PREFERRED_DETAILS[tier]:
258
+ downgraded_files[fp] = selected_detail
259
+
260
+ return {
261
+ "budget": budget,
262
+ "used": used,
263
+ "truncated": bool(omitted_files or downgraded_files),
264
+ "omitted_files": omitted_files,
265
+ "downgraded_files": downgraded_files,
266
+ "files": files_out,
267
+ }
268
+
269
+
270
+ def _build_entry(file_data: dict, priority: str, detail: str) -> dict:
271
+ """Serialize one file at a specific detail level."""
272
+ entry = _DETAIL_SERIALIZERS[detail](file_data)
273
+ entry["priority"] = priority
274
+ entry["detail"] = detail
275
+ return entry
276
+
277
+
278
+ def _entry_tokens(filepath: str, entry: dict) -> int:
279
+ return _estimate_tokens(json.dumps({filepath: entry}))
280
+
281
+
282
+ # ── Markdown renderer ─────────────────────────────────────────────────
283
+
284
+
285
+ def _render_markdown(payload: dict) -> str:
286
+ """Render the context payload as agent-friendly markdown."""
287
+ lines: list[str] = []
288
+ lines.append(f"# Context Budget: {payload['used']} / {payload['budget']} tokens")
289
+ lines.append("")
290
+
291
+ tier_labels = {
292
+ "high": "Changed Files (High Priority)",
293
+ "medium": "Neighbor Files (Medium Priority)",
294
+ "low": "Index (Low Priority)",
295
+ }
296
+
297
+ for tier in ("high", "medium", "low"):
298
+ tier_files = {
299
+ fp: data for fp, data in payload["files"].items()
300
+ if data.get("priority") == tier
301
+ }
302
+ if not tier_files:
303
+ continue
304
+
305
+ lines.append(f"## {tier_labels[tier]}")
306
+ lines.append("")
307
+
308
+ for fp, data in sorted(tier_files.items()):
309
+ lines.append(f"### `{fp}`")
310
+ lines.append("")
311
+
312
+ for cls in data.get("classes", []):
313
+ if isinstance(cls, str):
314
+ lines.append(f"- class **{cls}**")
315
+ else:
316
+ bases = ", ".join(cls.get("bases", []))
317
+ base_str = f"({bases})" if bases else ""
318
+ lines.append(f"- class **{cls['name']}**{base_str}")
319
+ if cls.get("docstring"):
320
+ lines.append(f" > {cls['docstring'].splitlines()[0]}")
321
+ for method in cls.get("methods", []):
322
+ params = ", ".join(
323
+ p.get("name", "") for p in method.get("params", [])
324
+ )
325
+ ret = f" → {method['return_type']}" if method.get("return_type") else ""
326
+ async_prefix = "async " if method.get("is_async") else ""
327
+ lines.append(f" - {async_prefix}`{method['name']}({params})`{ret}")
328
+
329
+ for fn in data.get("functions", []):
330
+ if isinstance(fn, str):
331
+ lines.append(f"- def **{fn}**()")
332
+ else:
333
+ params = ", ".join(
334
+ p.get("name", "") for p in fn.get("params", [])
335
+ )
336
+ ret = f" → {fn['return_type']}" if fn.get("return_type") else ""
337
+ async_prefix = "async " if fn.get("is_async") else ""
338
+ lines.append(f"- {async_prefix}def **{fn['name']}**({params}){ret}")
339
+ if fn.get("docstring"):
340
+ lines.append(f" > {fn['docstring'].splitlines()[0]}")
341
+
342
+ lines.append("")
343
+
344
+ omitted = payload.get("omitted_files", [])
345
+ if omitted:
346
+ lines.append("## Omitted Files")
347
+ lines.append("")
348
+ for fp in omitted:
349
+ lines.append(f"- `{fp}`")
350
+ lines.append("")
351
+
352
+ return "\n".join(lines)
353
+
354
+
355
+ # ── CLI entry point ───────────────────────────────────────────────────
356
+
357
+
358
+ def run(args) -> None:
359
+ src_dir: str = getattr(args, "src_dir", ".")
360
+ budget: int = getattr(args, "budget", 32000)
361
+ fmt: str = getattr(args, "format", "json")
362
+ focus: str = getattr(args, "focus", "changed")
363
+
364
+ validate_path(src_dir, "--src-dir")
365
+
366
+ # 1. Get full deep inventory (imports needed for graph building)
367
+ inventory_result = get_inventory_result(src_dir, deep=True)
368
+ if inventory_result.failed:
369
+ print_inventory_failures(inventory_result)
370
+ sys.exit(1)
371
+ inventory = inventory_result.inventory
372
+
373
+ if not inventory:
374
+ print("{}" if fmt == "json" else "No source files found.")
375
+ return
376
+
377
+ # 2. Determine changed files
378
+ changed: list[str] | None = None
379
+ if focus == "changed":
380
+ changed = _git_changed_files(src_dir)
381
+ if changed is None:
382
+ print("Warning: Could not get changed files from git. Treating all files as high priority.",
383
+ file=sys.stderr, flush=True)
384
+ focus = "all"
385
+ elif not changed:
386
+ print("Warning: No files changed in the last commit. Treating all files as high priority.",
387
+ file=sys.stderr, flush=True)
388
+ focus = "all"
389
+ else:
390
+ # Normalise changed paths to match inventory keys
391
+ changed = _normalise_changed_paths(changed, inventory)
392
+
393
+ # 3. Build import graph and classify files
394
+ import_graph = _build_import_graph(inventory)
395
+ classification = _classify_files(list(inventory.keys()), changed, import_graph, focus)
396
+
397
+ # 4. Build budgeted payload
398
+ payload = _build_context_payload(inventory, classification, budget)
399
+
400
+ # 5. Output
401
+ if fmt == "markdown":
402
+ print(_render_markdown(payload))
403
+ else:
404
+ print(json.dumps(payload, indent=2))
405
+
406
+
407
+ def _normalise_changed_paths(
408
+ changed: list[str], inventory: dict
409
+ ) -> list[str]:
410
+ """Match git-reported changed paths to inventory keys.
411
+
412
+ Git paths are relative to the repo root; inventory keys may include
413
+ a leading ``src/`` or similar prefix. This function tries exact
414
+ match first, then suffix match against inventory keys.
415
+ """
416
+ inv_keys = list(inventory.keys())
417
+ normalised: list[str] = []
418
+ for ch in changed:
419
+ if ch in inventory:
420
+ normalised.append(ch)
421
+ continue
422
+ # Try suffix matching
423
+ for key in inv_keys:
424
+ if key.endswith(ch) or ch.endswith(key):
425
+ normalised.append(key)
426
+ break
427
+ return normalised