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,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