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.
- agent_wiki_cli-0.3.28.dist-info/METADATA +425 -0
- agent_wiki_cli-0.3.28.dist-info/RECORD +47 -0
- agent_wiki_cli-0.3.28.dist-info/WHEEL +5 -0
- agent_wiki_cli-0.3.28.dist-info/entry_points.txt +2 -0
- agent_wiki_cli-0.3.28.dist-info/licenses/LICENSE +21 -0
- agent_wiki_cli-0.3.28.dist-info/top_level.txt +1 -0
- llm_wiki_cli/__init__.py +7 -0
- llm_wiki_cli/cli.py +231 -0
- llm_wiki_cli/commands/__init__.py +1 -0
- llm_wiki_cli/commands/bootstrap_cmd.py +1072 -0
- llm_wiki_cli/commands/bump_cmd.py +55 -0
- llm_wiki_cli/commands/context_cmd.py +427 -0
- llm_wiki_cli/commands/extract_cmd.py +745 -0
- llm_wiki_cli/commands/generate_prompt_cmd.py +89 -0
- llm_wiki_cli/commands/hook_cmd.py +161 -0
- llm_wiki_cli/commands/init_cmd.py +92 -0
- llm_wiki_cli/commands/lint_cmd.py +294 -0
- llm_wiki_cli/commands/migrate_cmd.py +892 -0
- llm_wiki_cli/commands/release_cmd.py +163 -0
- llm_wiki_cli/commands/status_cmd.py +70 -0
- llm_wiki_cli/commands/sync_cmd.py +521 -0
- llm_wiki_cli/commands/trigger_cmd.py +205 -0
- llm_wiki_cli/commands/uninstall_cmd.py +221 -0
- llm_wiki_cli/commands/upgrade_cmd.py +196 -0
- llm_wiki_cli/config.py +318 -0
- llm_wiki_cli/extractors/__init__.py +46 -0
- llm_wiki_cli/extractors/common.py +90 -0
- llm_wiki_cli/extractors/go_extractor.py +143 -0
- llm_wiki_cli/extractors/go_scripts/go.mod +3 -0
- llm_wiki_cli/extractors/go_scripts/main.go +668 -0
- llm_wiki_cli/extractors/python_extractor.py +346 -0
- llm_wiki_cli/extractors/rust_extractor.py +143 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.lock +110 -0
- llm_wiki_cli/extractors/rust_scripts/Cargo.toml +11 -0
- llm_wiki_cli/extractors/rust_scripts/src/main.rs +803 -0
- llm_wiki_cli/extractors/ts_extractor.py +206 -0
- llm_wiki_cli/extractors/ts_scripts/extract.js +485 -0
- llm_wiki_cli/extractors/ts_scripts/package.json +10 -0
- llm_wiki_cli/services/__init__.py +0 -0
- llm_wiki_cli/services/circuit_breaker.py +79 -0
- llm_wiki_cli/services/io.py +47 -0
- llm_wiki_cli/services/lockfile.py +60 -0
- llm_wiki_cli/services/packages.py +173 -0
- llm_wiki_cli/services/paths.py +31 -0
- llm_wiki_cli/services/schema.py +214 -0
- llm_wiki_cli/services/secure_file.py +22 -0
- llm_wiki_cli/services/versioning.py +193 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
MAX_CONSECUTIVE_FAILURES = 3
|
|
8
|
+
_STATE_FILE = "llm-wiki-breaker.json"
|
|
9
|
+
|
|
10
|
+
_DEFAULT_STATE = {
|
|
11
|
+
"consecutive_failures": 0,
|
|
12
|
+
"last_failure_ts": None,
|
|
13
|
+
"state": "closed",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _state_path(git_dir: Path) -> Path:
|
|
18
|
+
return git_dir / _STATE_FILE
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_state(git_dir: Path) -> dict:
|
|
22
|
+
path = _state_path(git_dir)
|
|
23
|
+
if not path.exists():
|
|
24
|
+
return dict(_DEFAULT_STATE)
|
|
25
|
+
try:
|
|
26
|
+
with open(path, encoding="utf-8") as f:
|
|
27
|
+
state = json.load(f)
|
|
28
|
+
except (OSError, json.JSONDecodeError):
|
|
29
|
+
return dict(_DEFAULT_STATE)
|
|
30
|
+
if not isinstance(state, dict):
|
|
31
|
+
return dict(_DEFAULT_STATE)
|
|
32
|
+
return {**_DEFAULT_STATE, **state}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save_state(git_dir: Path, state: dict) -> None:
|
|
36
|
+
"""Persist state atomically (write to tmp + rename)."""
|
|
37
|
+
path = _state_path(git_dir)
|
|
38
|
+
fd, tmp = tempfile.mkstemp(dir=git_dir, suffix=".tmp")
|
|
39
|
+
try:
|
|
40
|
+
with os.fdopen(fd, "w") as f:
|
|
41
|
+
json.dump(state, f, indent=2)
|
|
42
|
+
os.replace(tmp, path)
|
|
43
|
+
except BaseException:
|
|
44
|
+
try:
|
|
45
|
+
os.unlink(tmp)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass
|
|
48
|
+
raise
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check_breaker(git_dir: Path) -> bool:
|
|
52
|
+
"""Return True if the circuit is open (should block execution)."""
|
|
53
|
+
state = load_state(git_dir)
|
|
54
|
+
return state.get("state") == "open"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def record_success(git_dir: Path) -> None:
|
|
58
|
+
state = load_state(git_dir)
|
|
59
|
+
state["consecutive_failures"] = 0
|
|
60
|
+
state["state"] = "closed"
|
|
61
|
+
save_state(git_dir, state)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def record_failure(git_dir: Path) -> None:
|
|
65
|
+
state = load_state(git_dir)
|
|
66
|
+
state["consecutive_failures"] = state.get("consecutive_failures", 0) + 1
|
|
67
|
+
state["last_failure_ts"] = datetime.now(timezone.utc).isoformat()
|
|
68
|
+
if state["consecutive_failures"] >= MAX_CONSECUTIVE_FAILURES:
|
|
69
|
+
state["state"] = "open"
|
|
70
|
+
print(
|
|
71
|
+
f"Circuit breaker OPEN after {state['consecutive_failures']} consecutive failures."
|
|
72
|
+
)
|
|
73
|
+
print("Wiki auto-sync is now disabled.")
|
|
74
|
+
print("To re-enable: llm-wiki trigger-agent --reset-breaker")
|
|
75
|
+
save_state(git_dir, state)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def reset_breaker(git_dir: Path) -> None:
|
|
79
|
+
save_state(git_dir, dict(_DEFAULT_STATE))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Encoding-safe I/O helpers for wiki markdown files.
|
|
2
|
+
|
|
3
|
+
All wiki reads go through :func:`read_md` so that files containing
|
|
4
|
+
non-UTF-8 bytes (e.g. Windows cp1252 punctuation like ``0x97`` en-dash)
|
|
5
|
+
don't crash the tool. All writes go through :func:`write_md` to
|
|
6
|
+
normalize output to UTF-8 with Unix line-endings.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_md(path: Path) -> str:
|
|
17
|
+
"""Read a markdown file, tolerating non-UTF-8 encodings.
|
|
18
|
+
|
|
19
|
+
Tries UTF-8 first; if that fails, falls back to cp1252 which covers
|
|
20
|
+
common Windows-encoded punctuation (en-dash, em-dash, smart quotes).
|
|
21
|
+
"""
|
|
22
|
+
data = path.read_bytes()
|
|
23
|
+
try:
|
|
24
|
+
return data.decode("utf-8")
|
|
25
|
+
except UnicodeDecodeError:
|
|
26
|
+
return data.decode("cp1252")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_md(path: Path, text: str) -> None:
|
|
30
|
+
"""Write *text* to *path* as UTF-8 with Unix line-endings.
|
|
31
|
+
|
|
32
|
+
Writes through a same-directory temporary file and atomically replaces
|
|
33
|
+
the destination so an interrupted process does not leave a truncated page.
|
|
34
|
+
"""
|
|
35
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
38
|
+
try:
|
|
39
|
+
with os.fdopen(fd, "wb") as f:
|
|
40
|
+
f.write(normalized.encode("utf-8"))
|
|
41
|
+
os.replace(tmp, path)
|
|
42
|
+
except BaseException:
|
|
43
|
+
try:
|
|
44
|
+
os.unlink(tmp)
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
raise
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
_LOCK_SIZE = 4096
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LockAcquisitionError(Exception):
|
|
10
|
+
"""Raised when the wiki lock cannot be acquired (another sync is running)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WikiLock:
|
|
14
|
+
"""Exclusive file lock to prevent concurrent wiki syncs.
|
|
15
|
+
|
|
16
|
+
Uses fcntl.flock() on POSIX and msvcrt.locking() on Windows
|
|
17
|
+
for non-blocking exclusive locking on .git/llm-wiki.lock.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, git_dir: Path = Path(".git")):
|
|
21
|
+
self._lock_path = git_dir / "llm-wiki.lock"
|
|
22
|
+
self._fd = None
|
|
23
|
+
|
|
24
|
+
def __enter__(self):
|
|
25
|
+
self._fd = open(self._lock_path, "a+")
|
|
26
|
+
try:
|
|
27
|
+
if sys.platform == "win32":
|
|
28
|
+
import msvcrt
|
|
29
|
+
msvcrt.locking(self._fd.fileno(), msvcrt.LK_NBLCK, _LOCK_SIZE)
|
|
30
|
+
else:
|
|
31
|
+
import fcntl
|
|
32
|
+
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
33
|
+
except (BlockingIOError, OSError):
|
|
34
|
+
self._fd.close()
|
|
35
|
+
self._fd = None
|
|
36
|
+
raise LockAcquisitionError(
|
|
37
|
+
"Another llm-wiki sync is already running. Skipping."
|
|
38
|
+
)
|
|
39
|
+
# Write PID for diagnostics
|
|
40
|
+
self._fd.seek(0)
|
|
41
|
+
self._fd.truncate()
|
|
42
|
+
self._fd.write(str(os.getpid()))
|
|
43
|
+
self._fd.flush()
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
47
|
+
if self._fd is not None:
|
|
48
|
+
if sys.platform == "win32":
|
|
49
|
+
import msvcrt
|
|
50
|
+
try:
|
|
51
|
+
self._fd.seek(0)
|
|
52
|
+
msvcrt.locking(self._fd.fileno(), msvcrt.LK_UNLCK, _LOCK_SIZE)
|
|
53
|
+
except OSError:
|
|
54
|
+
print("Warning: failed to release wiki lock file.", file=sys.stderr)
|
|
55
|
+
else:
|
|
56
|
+
import fcntl
|
|
57
|
+
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
|
58
|
+
self._fd.close()
|
|
59
|
+
self._fd = None
|
|
60
|
+
return False
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Discover Python packages within a source tree.
|
|
2
|
+
|
|
3
|
+
Walks the directory tree under *src_dir* looking for ``pyproject.toml``
|
|
4
|
+
and ``setup.py`` markers, then extracts package metadata (name, version,
|
|
5
|
+
source root). Each discovered package is represented as a
|
|
6
|
+
:class:`PackageInfo` dataclass.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import configparser
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Sequence
|
|
16
|
+
|
|
17
|
+
from ..config import EXCLUDED_DIRS
|
|
18
|
+
|
|
19
|
+
try: # Python 3.11+
|
|
20
|
+
import tomllib
|
|
21
|
+
except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.9/3.10
|
|
22
|
+
try:
|
|
23
|
+
import tomli as tomllib
|
|
24
|
+
except ModuleNotFoundError: # pragma: no cover - dependency missing in ad-hoc envs
|
|
25
|
+
tomllib = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class PackageInfo:
|
|
30
|
+
"""Metadata for a single Python package discovered on disk."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
root: str # directory containing the package marker, relative to src_dir
|
|
34
|
+
version: str
|
|
35
|
+
marker_path: str # relative path of pyproject.toml / setup.py
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_pyproject_toml(text: str) -> dict[str, str]:
|
|
39
|
+
"""Parse project metadata from PEP 621 first, then Poetry metadata."""
|
|
40
|
+
info: dict[str, str] = {}
|
|
41
|
+
if tomllib is None:
|
|
42
|
+
return info
|
|
43
|
+
try:
|
|
44
|
+
data = tomllib.loads(text)
|
|
45
|
+
except Exception:
|
|
46
|
+
return info
|
|
47
|
+
|
|
48
|
+
project = data.get("project", {})
|
|
49
|
+
if isinstance(project, dict) and project.get("name"):
|
|
50
|
+
info["name"] = str(project["name"])
|
|
51
|
+
if isinstance(project.get("version"), str):
|
|
52
|
+
info["version"] = str(project["version"])
|
|
53
|
+
elif "version" in project.get("dynamic", []):
|
|
54
|
+
info["version"] = "dynamic"
|
|
55
|
+
return info
|
|
56
|
+
|
|
57
|
+
poetry = data.get("tool", {}).get("poetry", {})
|
|
58
|
+
if isinstance(poetry, dict) and poetry.get("name"):
|
|
59
|
+
info["name"] = str(poetry["name"])
|
|
60
|
+
if isinstance(poetry.get("version"), str):
|
|
61
|
+
info["version"] = str(poetry["version"])
|
|
62
|
+
|
|
63
|
+
return info
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_setup_py(text: str) -> dict[str, str]:
|
|
67
|
+
"""Extract *name* and *version* from a ``setup.py`` via AST inspection."""
|
|
68
|
+
info: dict[str, str] = {}
|
|
69
|
+
try:
|
|
70
|
+
tree = ast.parse(text)
|
|
71
|
+
except SyntaxError:
|
|
72
|
+
return info
|
|
73
|
+
|
|
74
|
+
for node in ast.walk(tree):
|
|
75
|
+
if not isinstance(node, ast.Call):
|
|
76
|
+
continue
|
|
77
|
+
func = node.func
|
|
78
|
+
# match setup(...) or setuptools.setup(...)
|
|
79
|
+
is_setup = (
|
|
80
|
+
(isinstance(func, ast.Name) and func.id == "setup")
|
|
81
|
+
or (isinstance(func, ast.Attribute) and func.attr == "setup")
|
|
82
|
+
)
|
|
83
|
+
if not is_setup:
|
|
84
|
+
continue
|
|
85
|
+
for kw in node.keywords:
|
|
86
|
+
if kw.arg in ("name", "version") and isinstance(kw.value, ast.Constant):
|
|
87
|
+
info[kw.arg] = str(kw.value.value)
|
|
88
|
+
return info
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def discover_packages(src_dir: str) -> list[PackageInfo]:
|
|
92
|
+
"""Return all Python packages found under *src_dir*.
|
|
93
|
+
|
|
94
|
+
A "package" is a directory containing ``pyproject.toml`` or
|
|
95
|
+
``setup.py`` with a discoverable project name. Directories matching
|
|
96
|
+
:data:`EXCLUDED_DIRS` are skipped.
|
|
97
|
+
"""
|
|
98
|
+
src_path = Path(src_dir).resolve()
|
|
99
|
+
packages: list[PackageInfo] = []
|
|
100
|
+
|
|
101
|
+
for marker in sorted(src_path.rglob("pyproject.toml")):
|
|
102
|
+
rel = marker.relative_to(src_path)
|
|
103
|
+
if not EXCLUDED_DIRS.isdisjoint(rel.parts):
|
|
104
|
+
continue
|
|
105
|
+
try:
|
|
106
|
+
text = marker.read_text(encoding="utf-8")
|
|
107
|
+
except (OSError, UnicodeDecodeError):
|
|
108
|
+
continue
|
|
109
|
+
info = _parse_pyproject_toml(text)
|
|
110
|
+
name = info.get("name", "")
|
|
111
|
+
if not name:
|
|
112
|
+
continue
|
|
113
|
+
packages.append(PackageInfo(
|
|
114
|
+
name=name,
|
|
115
|
+
root=rel.parent.as_posix() if rel.parent != Path(".") else ".",
|
|
116
|
+
version=info.get("version", "0.0.0"),
|
|
117
|
+
marker_path=rel.as_posix(),
|
|
118
|
+
))
|
|
119
|
+
|
|
120
|
+
for marker in sorted(src_path.rglob("setup.py")):
|
|
121
|
+
rel = marker.relative_to(src_path)
|
|
122
|
+
if not EXCLUDED_DIRS.isdisjoint(rel.parts):
|
|
123
|
+
continue
|
|
124
|
+
# Skip if a pyproject.toml already covers this directory
|
|
125
|
+
rel_root = rel.parent.as_posix() if rel.parent != Path(".") else "."
|
|
126
|
+
if any(p.root == rel_root for p in packages):
|
|
127
|
+
continue
|
|
128
|
+
try:
|
|
129
|
+
text = marker.read_text(encoding="utf-8")
|
|
130
|
+
except (OSError, UnicodeDecodeError):
|
|
131
|
+
continue
|
|
132
|
+
info = _parse_setup_py(text)
|
|
133
|
+
name = info.get("name", "")
|
|
134
|
+
if not name:
|
|
135
|
+
continue
|
|
136
|
+
packages.append(PackageInfo(
|
|
137
|
+
name=name,
|
|
138
|
+
root=rel.parent.as_posix() if rel.parent != Path(".") else ".",
|
|
139
|
+
version=info.get("version", "0.0.0"),
|
|
140
|
+
marker_path=rel.as_posix(),
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
return packages
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def stamp_inventory_packages(
|
|
147
|
+
inventory: dict,
|
|
148
|
+
packages: Sequence[PackageInfo],
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Add a ``"package"`` key to each inventory entry in-place.
|
|
151
|
+
|
|
152
|
+
Files are matched to the *most specific* (longest root) package
|
|
153
|
+
whose root is a prefix of the file path. Files that don't belong
|
|
154
|
+
to any package get ``package: None``.
|
|
155
|
+
"""
|
|
156
|
+
# Sort packages longest-root-first for greedy matching
|
|
157
|
+
sorted_pkgs = sorted(packages, key=lambda p: len(p.root), reverse=True)
|
|
158
|
+
|
|
159
|
+
for filepath, data in inventory.items():
|
|
160
|
+
if data.get("language") != "python":
|
|
161
|
+
data["package"] = None
|
|
162
|
+
continue
|
|
163
|
+
fp_posix = filepath.replace("\\", "/")
|
|
164
|
+
matched = None
|
|
165
|
+
for pkg in sorted_pkgs:
|
|
166
|
+
prefix = pkg.root
|
|
167
|
+
if prefix == ".":
|
|
168
|
+
matched = pkg.name
|
|
169
|
+
break
|
|
170
|
+
if fp_posix == prefix or fp_posix.startswith(prefix + "/"):
|
|
171
|
+
matched = pkg.name
|
|
172
|
+
break
|
|
173
|
+
data["package"] = matched
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared path normalization helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def normalize_source_path(value: str | None, src_dir: str | None = None) -> str | None:
|
|
10
|
+
"""Normalize a source path from generated markdown or Docker instructions."""
|
|
11
|
+
if not value:
|
|
12
|
+
return None
|
|
13
|
+
normalized = value.strip().strip("`").strip().strip('"').strip("'")
|
|
14
|
+
normalized = normalized.replace("\\", "/")
|
|
15
|
+
while normalized.startswith("./"):
|
|
16
|
+
normalized = normalized[2:]
|
|
17
|
+
|
|
18
|
+
candidate = Path(normalized)
|
|
19
|
+
if src_dir and candidate.is_absolute():
|
|
20
|
+
try:
|
|
21
|
+
return candidate.resolve().relative_to(Path(src_dir).resolve()).as_posix()
|
|
22
|
+
except ValueError:
|
|
23
|
+
return candidate.as_posix()
|
|
24
|
+
return normalized
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def shell_quote(value: str | Path) -> str:
|
|
28
|
+
"""Quote a value for POSIX shell snippets, including Git Bash on Windows."""
|
|
29
|
+
if isinstance(value, Path):
|
|
30
|
+
return shlex.quote(value.as_posix())
|
|
31
|
+
return shlex.quote(str(value))
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Shared schema utilities for agent constraint blocks.
|
|
2
|
+
|
|
3
|
+
Provides functions to build, strip, and replace the LLM Wiki constraint
|
|
4
|
+
block that is injected into agent schema files (CLAUDE.md, .cursorrules, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ..config import IDE_AGENTS
|
|
13
|
+
from .io import read_md, write_md
|
|
14
|
+
|
|
15
|
+
# Marker boundaries used to wrap the entire generated block
|
|
16
|
+
CONSTRAINT_START = "# --- LLM Wiki Maintainer Constraints ---"
|
|
17
|
+
CONSTRAINT_END = "# --- End LLM Wiki Constraints ---"
|
|
18
|
+
|
|
19
|
+
# Map from agent name to the schema file it uses
|
|
20
|
+
SCHEMA_FILENAMES: dict[str, str] = {
|
|
21
|
+
"claude": "CLAUDE.md",
|
|
22
|
+
"cursor": ".cursorrules",
|
|
23
|
+
"copilot": ".github/copilot-instructions.md",
|
|
24
|
+
"aider": ".aider.conf.yml",
|
|
25
|
+
"opencode": ".opencode/instructions.md",
|
|
26
|
+
"generic": ".agents.md",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# All possible schema files (superset — includes legacy names for uninstall)
|
|
30
|
+
ALL_SCHEMA_FILES: list[str] = [
|
|
31
|
+
"CLAUDE.md",
|
|
32
|
+
"AGENTS.md",
|
|
33
|
+
".cursorrules",
|
|
34
|
+
".github/copilot-instructions.md",
|
|
35
|
+
".agents.md",
|
|
36
|
+
".aider.conf.yml",
|
|
37
|
+
".opencode/instructions.md",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_IDE_SYNC_INSTRUCTIONS = """\
|
|
41
|
+
|
|
42
|
+
## How to sync the wiki in this IDE session
|
|
43
|
+
Because you run inside the IDE (not as a background CLI process), the wiki is NOT
|
|
44
|
+
updated automatically on commit. You are responsible for keeping it current:
|
|
45
|
+
|
|
46
|
+
1. **After every code change in this session** that adds, removes, or modifies a
|
|
47
|
+
class, function, module, or cross-module flow:
|
|
48
|
+
- Update the affected `entities/`, `modules/`, `workflows/`, and `infrastructure/` pages.
|
|
49
|
+
- Append a one-line summary to `log.md`.
|
|
50
|
+
- Run `llm-wiki lint` to verify your changes. Fix any issues until it exits 0.
|
|
51
|
+
2. **To do a full re-sync manually**, run in the terminal:
|
|
52
|
+
```
|
|
53
|
+
llm-wiki generate-prompt
|
|
54
|
+
```
|
|
55
|
+
This builds a diff + AST prompt in `.git/llm-wiki-prompt.txt`. Open that file
|
|
56
|
+
and paste its contents into this chat to trigger a full wiki update.
|
|
57
|
+
3. **Never skip the update** — a stale wiki defeats the purpose of the system.
|
|
58
|
+
|
|
59
|
+
## Using `llm-wiki sync` for incremental updates
|
|
60
|
+
`sync` compares source file hashes against a stored manifest and regenerates only
|
|
61
|
+
the wiki pages whose source has changed. Use it instead of a full re-bootstrap:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
llm-wiki sync
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- **When to use:** after pulling new code, after a rebase, or whenever you suspect
|
|
68
|
+
the wiki is stale but don't want to regenerate everything.
|
|
69
|
+
- Sync creates/updates entity and module pages for new or changed files, marks
|
|
70
|
+
removed files with a ⚠️ Stale header, and rebuilds `index.md`.
|
|
71
|
+
- If no manifest exists yet (project bootstrapped by an older version), sync will
|
|
72
|
+
**seed a baseline manifest** from the current source state without modifying
|
|
73
|
+
pages. Subsequent runs then work incrementally.
|
|
74
|
+
- After sync finishes, always run `llm-wiki lint` to verify consistency.
|
|
75
|
+
|
|
76
|
+
## Using `llm-wiki context` for large codebases
|
|
77
|
+
`context` produces a token-budgeted, priority-ranked snapshot of the codebase —
|
|
78
|
+
ideal for feeding into an LLM prompt when the full extract output is too large:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
llm-wiki context --budget <TOKENS> --src-dir . --format markdown --focus changed
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- **`--budget`** (required): maximum token count for the output.
|
|
85
|
+
- **`--focus changed`** (default): prioritises files from the last git commit.
|
|
86
|
+
Changed files get full detail, their 1-hop import neighbours get slim detail,
|
|
87
|
+
everything else gets names only. Use `--focus all` to treat every file equally.
|
|
88
|
+
- **`--format`**: `json` (default, structured) or `markdown` (human-readable with
|
|
89
|
+
tier-labelled sections).
|
|
90
|
+
- **When to use:** before starting a complex task on a large project, pass the
|
|
91
|
+
context output to the agent so it has an accurate, right-sized view of the
|
|
92
|
+
codebase without exceeding the context window.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
_QUALITY_HINTS = """\
|
|
96
|
+
|
|
97
|
+
## Agent quality guidelines
|
|
98
|
+
- **Surgical Changes:** Only modify wiki pages directly affected by your code change.
|
|
99
|
+
Don't "improve" adjacent pages, reformat existing content, or refactor unrelated docs.
|
|
100
|
+
- **Think Before Editing:** If a wiki page structure is unclear, state what's confusing
|
|
101
|
+
rather than guessing. Don't silently rewrite pages you don't fully understand.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _wiki_instructions(wiki_dir: str) -> str:
|
|
106
|
+
return f"""You are operating within an LLM Wiki architecture. The project's persistent memory is stored in `{wiki_dir}/`.
|
|
107
|
+
|
|
108
|
+
## Before you start
|
|
109
|
+
- ALWAYS read `{wiki_dir}/index.md` before planning a new feature or making architectural changes.
|
|
110
|
+
- Consult relevant entity and module pages to understand existing patterns before writing new code.
|
|
111
|
+
|
|
112
|
+
## When you change code
|
|
113
|
+
- UPDATE entity pages in `{wiki_dir}/entities/` when you add, modify, or remove a class.
|
|
114
|
+
- UPDATE module pages in `{wiki_dir}/modules/` when you add, modify, or remove a module.
|
|
115
|
+
- UPDATE `{wiki_dir}/workflows/<name>.md` when a cross-module flow changes.
|
|
116
|
+
- UPDATE infrastructure pages in `{wiki_dir}/infrastructure/` when a Dockerfile or docker-compose file changes.
|
|
117
|
+
- LOG a concise summary of your changes in `{wiki_dir}/log.md` (append-only, newest at bottom).
|
|
118
|
+
|
|
119
|
+
## Wiki file naming rules
|
|
120
|
+
Page filenames **must** match the conventions enforced by `llm-wiki lint`:
|
|
121
|
+
|
|
122
|
+
- Treat `{wiki_dir}/index.md` as the source of truth for existing page names.
|
|
123
|
+
Do not guess links from raw class names or filenames when collisions are
|
|
124
|
+
possible. If in doubt, run `llm-wiki extract --src-dir .` and match the
|
|
125
|
+
source path to the existing index entry.
|
|
126
|
+
- **Entity pages** (`entities/`): Use the class name as the file stem when it is
|
|
127
|
+
unique (e.g., class `MyClass` → `MyClass.md`). When two classes in different
|
|
128
|
+
modules share the same name, prefix with the disambiguated module page stem:
|
|
129
|
+
`<module_page_stem>_<ClassName>.md` (e.g., `pkg_a_cli_Parser.md`).
|
|
130
|
+
- **Module pages** (`modules/`): Use the source path from the extractor,
|
|
131
|
+
relative to `--src-dir`. A unique file stem uses `<stem>.md`
|
|
132
|
+
(e.g., `cli.py` → `cli.md`, `main.rs` → `main.md`). When two files share the
|
|
133
|
+
same stem in different directories, parent directory components are prepended
|
|
134
|
+
with underscores until unique (e.g., `pkg_a/cli.py` and `pkg_b/cli.py` →
|
|
135
|
+
`pkg_a_cli.md` and `pkg_b_cli.md`).
|
|
136
|
+
- **Infrastructure pages** (`infrastructure/`): Take the relative path of the
|
|
137
|
+
Docker/Compose file and replace `/` and `.` with `_`
|
|
138
|
+
(e.g., `Dockerfile` → `Dockerfile.md`, `test_project/Dockerfile` →
|
|
139
|
+
`test_project_Dockerfile.md`, `docker-compose.yml` → `docker-compose_yml.md`).
|
|
140
|
+
Links from infrastructure pages to source modules must target the actual
|
|
141
|
+
module page stem from `index.md`; if a COPY/ADD source is ambiguous, leave it
|
|
142
|
+
as code text instead of creating a guessed link.
|
|
143
|
+
- **Workflow pages** (`workflows/`): Free-form descriptive names.
|
|
144
|
+
|
|
145
|
+
## Quality checks
|
|
146
|
+
- Your wiki changes are **complete** when `llm-wiki lint --wiki-dir {wiki_dir} --src-dir .` exits 0.
|
|
147
|
+
- Run lint after every wiki update. If it reports issues, fix them and re-run until it passes.
|
|
148
|
+
- Run `llm-wiki extract --src-dir .` to see the live AST inventory when you need detail.
|
|
149
|
+
- Never leave the wiki in a state where lint reports errors.
|
|
150
|
+
|
|
151
|
+
## Formatting rules
|
|
152
|
+
- Entity pages must have: Location, Bases, Module link, Attributes table, Methods table, Relationships.
|
|
153
|
+
- Module pages must have: Path, Imports table, Classes summary, Functions table.
|
|
154
|
+
- Infrastructure pages must have: Path, type-specific sections (stages, services, ports, env vars, etc.).
|
|
155
|
+
- Use relative markdown links between pages (e.g., `../entities/User.md`).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_schema_content(agent: str, wiki_dir: str, *, quality_hints: bool = True) -> str:
|
|
160
|
+
"""Build the full constraint block for the given agent and wiki directory."""
|
|
161
|
+
instructions = _wiki_instructions(wiki_dir)
|
|
162
|
+
preambles = {
|
|
163
|
+
"claude": f"# Project Wiki\n\nThis project uses an LLM Wiki for persistent architectural memory.\nRead `{wiki_dir}/index.md` first when starting any task.\n\n",
|
|
164
|
+
"cursor": f"# Cursor Rules — LLM Wiki Project\n\nThis project maintains a living wiki at `{wiki_dir}/`.\nAlways consult it before making changes.\n\n",
|
|
165
|
+
"copilot": f"# Copilot Instructions — LLM Wiki Project\n\nThis project uses `{wiki_dir}/` as persistent documentation.\nConsult the wiki before suggesting changes.\n\n",
|
|
166
|
+
}
|
|
167
|
+
preamble = preambles.get(agent, f"# Agent Instructions — LLM Wiki Project\n\nThis project uses `{wiki_dir}/` for architectural memory.\n\n")
|
|
168
|
+
hints = _QUALITY_HINTS if quality_hints else ""
|
|
169
|
+
extra = _IDE_SYNC_INSTRUCTIONS if agent in IDE_AGENTS else ""
|
|
170
|
+
body = preamble + instructions + hints + extra
|
|
171
|
+
return f"{CONSTRAINT_START}\n{body.strip()}\n{CONSTRAINT_END}\n"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def strip_wiki_block(content: str) -> str:
|
|
175
|
+
"""Remove the LLM Wiki constraint block from file content.
|
|
176
|
+
|
|
177
|
+
Handles the block including surrounding blank lines so the file
|
|
178
|
+
stays clean after removal.
|
|
179
|
+
"""
|
|
180
|
+
pattern = re.compile(
|
|
181
|
+
r'\n*' + re.escape(CONSTRAINT_START) + r'.*?' + re.escape(CONSTRAINT_END) + r'\n*',
|
|
182
|
+
re.DOTALL,
|
|
183
|
+
)
|
|
184
|
+
cleaned = pattern.sub('\n', content)
|
|
185
|
+
return cleaned.strip() + '\n' if cleaned.strip() else ''
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def replace_schema_block(schema_path: Path, new_content: str) -> None:
|
|
189
|
+
"""Replace the constraint block in an existing schema file, preserving user content.
|
|
190
|
+
|
|
191
|
+
If the file has no existing block, the new content is appended.
|
|
192
|
+
"""
|
|
193
|
+
if not schema_path.exists():
|
|
194
|
+
schema_path.parent.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
write_md(schema_path, new_content)
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
existing = read_md(schema_path)
|
|
199
|
+
# Normalize newlines for consistent matching
|
|
200
|
+
existing = existing.replace("\r\n", "\n").replace("\r", "\n")
|
|
201
|
+
if CONSTRAINT_START not in existing:
|
|
202
|
+
# No existing block — append
|
|
203
|
+
sep = "\n\n" if existing and not existing.endswith("\n\n") else ("\n" if existing and not existing.endswith("\n") else "")
|
|
204
|
+
write_md(schema_path, existing + sep + new_content)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
# Replace existing block (consume any trailing whitespace after CONSTRAINT_END
|
|
208
|
+
# so repeated runs don't accumulate blank lines)
|
|
209
|
+
pattern = re.compile(
|
|
210
|
+
re.escape(CONSTRAINT_START) + r'.*?' + re.escape(CONSTRAINT_END) + r'\n*',
|
|
211
|
+
re.DOTALL,
|
|
212
|
+
)
|
|
213
|
+
updated = pattern.sub(lambda _m: new_content, existing)
|
|
214
|
+
write_md(schema_path, updated)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Helpers for writing local runtime files with best-effort privacy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_private_text(path: str | Path, text: str, *, encoding: str = "utf-8") -> Path:
|
|
10
|
+
"""Write text and restrict file permissions where the platform supports it.
|
|
11
|
+
|
|
12
|
+
POSIX platforms support owner-only mode bits. Windows' ``chmod`` support is
|
|
13
|
+
limited to read-only toggling, so this remains a best-effort operation there.
|
|
14
|
+
"""
|
|
15
|
+
target = Path(path)
|
|
16
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
target.write_text(text, encoding=encoding)
|
|
18
|
+
try:
|
|
19
|
+
os.chmod(target, 0o600)
|
|
20
|
+
except OSError:
|
|
21
|
+
pass
|
|
22
|
+
return target
|