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,205 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+ from ..services.lockfile import WikiLock, LockAcquisitionError
6
+ from ..services import circuit_breaker
7
+ from ..services.secure_file import write_private_text
8
+ from ..config import DEFAULT_WIKI_DIR, IDE_AGENTS, validate_path
9
+ import json
10
+
11
+ GIT_DIR = Path(".git")
12
+ DEFAULT_MAX_PROMPT_BYTES = 2_000_000
13
+
14
+
15
+ def run(args):
16
+ # Handle --reset-breaker early (no lock needed)
17
+ if getattr(args, "reset_breaker", False):
18
+ circuit_breaker.reset_breaker(GIT_DIR)
19
+ print("Circuit breaker reset. Wiki auto-sync is re-enabled.")
20
+ return
21
+
22
+ if args.agent in IDE_AGENTS:
23
+ print(f"Error: Agent '{args.agent}' is a UI-based assistant for IDEs.")
24
+ print(f"To use background auto-sync, you must specify a CLI-native agent like 'claude' or 'aider'.")
25
+ print(f"Example: llm-wiki trigger-agent --agent claude")
26
+ sys.exit(1)
27
+
28
+ # --- Fuse: Concurrency Lock ---
29
+ try:
30
+ with WikiLock(GIT_DIR):
31
+ _run_sync(args)
32
+ except LockAcquisitionError:
33
+ print("Another llm-wiki sync is already running. Skipping.")
34
+
35
+
36
+ def _run_sync(args):
37
+ """Core sync logic, executed inside the concurrency lock."""
38
+
39
+ wiki_dir = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
40
+ validate_path(wiki_dir, "--wiki-dir")
41
+
42
+ # --- Fuse: Circuit Breaker ---
43
+ if circuit_breaker.check_breaker(GIT_DIR):
44
+ print("Circuit breaker is OPEN — wiki auto-sync is disabled after repeated failures.")
45
+ print("To re-enable: llm-wiki trigger-agent --reset-breaker")
46
+ return
47
+
48
+ print("Triggering auto-sync workflow for LLM Wiki...")
49
+
50
+ # 1. Get the git diff
51
+ print("Fetching git diff...")
52
+ try:
53
+ git_diff_result = subprocess.run(
54
+ ["git", "diff", "HEAD~1..HEAD"],
55
+ capture_output=True, text=True, check=True, timeout=30,
56
+ )
57
+ diff_text = git_diff_result.stdout
58
+ except subprocess.TimeoutExpired:
59
+ print("Git diff timed out (30s). Aborting.")
60
+ return
61
+ except (subprocess.CalledProcessError, FileNotFoundError) as e:
62
+ print(f"Git diff failed. Are there commits? {e}")
63
+ return
64
+
65
+ if not diff_text.strip():
66
+ print("No changes in the last commit. Aborting.")
67
+ return
68
+
69
+ # --- Fuse: Diff Size Guard ---
70
+ max_diff = getattr(args, "max_diff_lines", 1000)
71
+ force = getattr(args, "force", False)
72
+ diff_lines = len(diff_text.splitlines())
73
+ if diff_lines > max_diff and not force:
74
+ print(f"Diff too large ({diff_lines} lines > {max_diff} limit). Skipping auto-sync.")
75
+ print("Use --force to override, or increase --max-diff-lines.")
76
+ return
77
+
78
+ # 2. Extract context via current AST
79
+ print("Extracting current structure context...")
80
+ from .extract_cmd import get_call_graph, get_inventory_result, print_inventory_failures
81
+ inventory_result = get_inventory_result(".", deep=True)
82
+ if inventory_result.failed:
83
+ print_inventory_failures(inventory_result)
84
+ return
85
+ inventory = inventory_result.inventory
86
+ ast_json = json.dumps(inventory, indent=2)
87
+
88
+ # 2b. Build call graph for workflow awareness
89
+ call_graph = get_call_graph(inventory)
90
+ graph_json = json.dumps(call_graph, indent=2)
91
+
92
+ # 3. Create context prompt for the subagent
93
+ prompt = f"""
94
+ You are a Wiki synchronizer subagent.
95
+ A new commit was just made. Your task is to update the wiki at `{wiki_dir}/`.
96
+
97
+ ## Context
98
+
99
+ AST structure of the codebase:
100
+ {ast_json}
101
+
102
+ Cross-module call graph (functions touching 3+ internal modules):
103
+ {graph_json}
104
+
105
+ Git diff:
106
+ {diff_text}
107
+
108
+ ## Success Criteria
109
+
110
+ Your work is done when **all** of the following are true:
111
+
112
+ 1. **`llm-wiki lint --wiki-dir {wiki_dir} --src-dir .` exits 0** — no broken links, \
113
+ no orphan pages, no undocumented classes, no stale entities, no missing modules, \
114
+ no broken workflow links, no undocumented infrastructure files.
115
+ 2. **Only affected pages changed** — modify wiki pages that correspond to code \
116
+ touched in the diff. Do not edit unrelated pages or reformat existing content.
117
+ 3. **`{wiki_dir}/log.md` has a new entry** — one concise line describing what changed, \
118
+ appended at the bottom.
119
+
120
+ ## Loop
121
+
122
+ After making your changes, run:
123
+
124
+ ```bash
125
+ llm-wiki lint --wiki-dir {wiki_dir} --src-dir .
126
+ ```
127
+
128
+ If lint reports issues, fix them and re-run. **Repeat until lint exits 0.** Then commit:
129
+
130
+ ```bash
131
+ git add {wiki_dir}/
132
+ LLM_WIKI_AUTO_COMMIT=1 git commit -m "docs(wiki): auto-update [bot]"
133
+ ```
134
+ """
135
+
136
+ max_prompt_bytes = _max_prompt_bytes(args)
137
+ prompt_bytes = len(prompt.encode("utf-8"))
138
+ if prompt_bytes > max_prompt_bytes and not force:
139
+ print(
140
+ f"Prompt too large ({prompt_bytes} bytes > {max_prompt_bytes} limit). "
141
+ "Skipping auto-sync."
142
+ )
143
+ print("Use --force to override, or increase --max-prompt-bytes.")
144
+ return
145
+
146
+ # 4. Save the prompt to a temp file
147
+ prompt_file = Path(".git/llm-wiki-prompt.txt")
148
+ write_private_text(prompt_file, prompt)
149
+
150
+ # 5. Delegate to Subagent via CLI
151
+ print(f"Delegating to {args.agent} subagent...")
152
+ if args.agent == "claude":
153
+ cmd = ["claude", "-p", "--dangerously-skip-permissions"]
154
+ elif args.agent == "aider":
155
+ cmd = ["aider", "--message-file", str(prompt_file), "--no-auto-commits"]
156
+ elif args.agent == "opencode":
157
+ cmd = ["opencode", "task", "-f", str(prompt_file)]
158
+ else:
159
+ print(f"Unsupported agent {args.agent}")
160
+ return
161
+
162
+ timeout = getattr(args, "timeout", 300)
163
+
164
+ try:
165
+ print(f"Running command: {' '.join(cmd)}")
166
+
167
+ if args.agent == "claude":
168
+ with open(prompt_file, "r", encoding="utf-8") as prompt_in:
169
+ result = subprocess.run(
170
+ cmd, stdin=prompt_in,
171
+ timeout=timeout,
172
+ )
173
+ else:
174
+ with open(os.devnull, "r") as devnull:
175
+ result = subprocess.run(
176
+ cmd, stdin=devnull,
177
+ timeout=timeout,
178
+ )
179
+
180
+ # --- Fuse: record success / failure based on exit code ---
181
+ if result.returncode != 0:
182
+ print(f"Subagent exited with code {result.returncode}.")
183
+ circuit_breaker.record_failure(GIT_DIR)
184
+ else:
185
+ circuit_breaker.record_success(GIT_DIR)
186
+
187
+ except subprocess.TimeoutExpired:
188
+ print(f"Subagent timed out after {timeout}s. Process killed.")
189
+ circuit_breaker.record_failure(GIT_DIR)
190
+ except Exception as e:
191
+ print(f"Error executing agent {args.agent}: {e}")
192
+ circuit_breaker.record_failure(GIT_DIR)
193
+
194
+
195
+ def _max_prompt_bytes(args) -> int:
196
+ value = getattr(args, "max_prompt_bytes", None)
197
+ if value is not None:
198
+ return int(value)
199
+ env_value = os.environ.get("LLM_WIKI_MAX_PROMPT_BYTES")
200
+ if env_value:
201
+ try:
202
+ return int(env_value)
203
+ except ValueError:
204
+ pass
205
+ return DEFAULT_MAX_PROMPT_BYTES
@@ -0,0 +1,221 @@
1
+ import shutil
2
+ from pathlib import Path
3
+
4
+ from ..config import DEFAULT_WIKI_DIR, validate_path
5
+ from ..services.io import read_md, write_md
6
+ from ..services.schema import (
7
+ ALL_SCHEMA_FILES as AGENT_SCHEMA_FILES,
8
+ CONSTRAINT_START,
9
+ CONSTRAINT_END,
10
+ strip_wiki_block as _strip_wiki_block,
11
+ )
12
+
13
+ # Hook identifier — all llm-wiki hooks contain this string
14
+ HOOK_SIGNATURE = "LLM Wiki"
15
+
16
+ # Hooks that install-hook may have written
17
+ HOOK_NAMES = ["post-commit", "pre-commit", "pre-push"]
18
+
19
+ # Local runtime artifacts created by init/hooks/trigger-agent.
20
+ RUNTIME_ARTIFACTS = [
21
+ ".git/.llm-wiki-agent",
22
+ ".git/llm-wiki-prompt.txt",
23
+ ".git/llm-wiki.lock",
24
+ ".git/llm-wiki-breaker.json",
25
+ ".git/llm-wiki-sync.log",
26
+ ]
27
+
28
+
29
+ def _confirm(prompt: str) -> bool:
30
+ """Ask for y/n confirmation."""
31
+ try:
32
+ answer = input(f" {prompt} [y/N]: ").strip().lower()
33
+ except (EOFError, KeyboardInterrupt):
34
+ print()
35
+ return False
36
+ return answer in ("y", "yes")
37
+
38
+
39
+ def _remove_hooks(dry_run: bool = False) -> int:
40
+ """Remove llm-wiki hooks, but only if they contain our signature."""
41
+ hooks_dir = Path(".git/hooks")
42
+ removed = 0
43
+
44
+ if not hooks_dir.exists():
45
+ return 0
46
+
47
+ for name in HOOK_NAMES:
48
+ hook_path = hooks_dir / name
49
+ if not hook_path.exists():
50
+ continue
51
+
52
+ content = hook_path.read_text(encoding="utf-8")
53
+ if HOOK_SIGNATURE not in content:
54
+ print(f" SKIP hook {name} (not ours — contains custom user content)")
55
+ continue
56
+
57
+ if dry_run:
58
+ print(f" WOULD REMOVE hook: {hook_path}")
59
+ else:
60
+ hook_path.unlink()
61
+ print(f" REMOVED hook: {hook_path}")
62
+ removed += 1
63
+
64
+ return removed
65
+
66
+
67
+ def _clean_agent_schemas(dry_run: bool = False) -> int:
68
+ """Remove the LLM Wiki constraint block from agent schema files.
69
+
70
+ If the file becomes empty after block removal, delete it entirely.
71
+ If user content remains, preserve it.
72
+ """
73
+ cleaned = 0
74
+
75
+ for filename in AGENT_SCHEMA_FILES:
76
+ schema_path = Path(filename)
77
+ if not schema_path.exists():
78
+ continue
79
+
80
+ content = read_md(schema_path)
81
+ if CONSTRAINT_START not in content:
82
+ continue
83
+
84
+ stripped = _strip_wiki_block(content)
85
+
86
+ if dry_run:
87
+ if stripped:
88
+ print(f" WOULD CLEAN block from: {filename} (user content preserved)")
89
+ else:
90
+ print(f" WOULD DELETE: {filename} (only contained wiki constraints)")
91
+ else:
92
+ if stripped:
93
+ write_md(schema_path, stripped)
94
+ print(f" CLEANED block from: {filename} (user content preserved)")
95
+ else:
96
+ schema_path.unlink()
97
+ print(f" DELETED: {filename} (only contained wiki constraints)")
98
+ cleaned += 1
99
+
100
+ return cleaned
101
+
102
+
103
+ def _remove_wiki_dir(wiki_dir: Path, dry_run: bool = False) -> bool:
104
+ """Remove the wiki directory tree."""
105
+ if not wiki_dir.exists():
106
+ return False
107
+
108
+ if dry_run:
109
+ page_count = len(list(wiki_dir.rglob("*.md")))
110
+ print(f" WOULD REMOVE: {wiki_dir}/ ({page_count} markdown files)")
111
+ else:
112
+ shutil.rmtree(wiki_dir)
113
+ print(f" REMOVED: {wiki_dir}/")
114
+ return True
115
+
116
+
117
+ def _remove_runtime_artifacts(dry_run: bool = False) -> int:
118
+ """Remove local runtime artifacts created by llm-wiki."""
119
+ removed = 0
120
+ for filepath in RUNTIME_ARTIFACTS:
121
+ p = Path(filepath)
122
+ if p.exists():
123
+ if dry_run:
124
+ print(f" WOULD REMOVE: {filepath}")
125
+ else:
126
+ p.unlink()
127
+ print(f" REMOVED: {filepath}")
128
+ removed += 1
129
+ return removed
130
+
131
+
132
+ def run(args):
133
+ wiki_dir_arg = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
134
+ validate_path(str(wiki_dir_arg), "--wiki-dir")
135
+ wiki_dir = Path(wiki_dir_arg)
136
+ remove_wiki = getattr(args, "remove_wiki", False)
137
+ dry_run = getattr(args, "dry_run", False)
138
+
139
+ if dry_run:
140
+ print("DRY RUN — no files will be modified:\n")
141
+
142
+ # ── 1. Preview what will be removed ──────────────────────────────
143
+ print("LLM Wiki Uninstall")
144
+ print("=" * 40)
145
+
146
+ # Hooks
147
+ print("\n1. Git Hooks:")
148
+ hooks_count = _remove_hooks(dry_run=True)
149
+ if hooks_count == 0:
150
+ print(" Nothing to remove.")
151
+
152
+ # Agent schemas
153
+ print("\n2. Agent Constraint Blocks:")
154
+ schema_count = 0
155
+ for filename in AGENT_SCHEMA_FILES:
156
+ p = Path(filename)
157
+ if p.exists() and CONSTRAINT_START in read_md(p):
158
+ stripped = _strip_wiki_block(read_md(p))
159
+ if stripped:
160
+ print(f" {filename} — will strip wiki block (user content preserved)")
161
+ else:
162
+ print(f" {filename} — will delete (only wiki constraints)")
163
+ schema_count += 1
164
+ if schema_count == 0:
165
+ print(" Nothing to remove.")
166
+
167
+ # Wiki dir
168
+ print("\n3. Wiki Directory:")
169
+ if remove_wiki and wiki_dir.exists():
170
+ page_count = len(list(wiki_dir.rglob("*.md")))
171
+ print(f" {wiki_dir}/ — {page_count} markdown file(s)")
172
+ elif wiki_dir.exists():
173
+ print(f" {wiki_dir}/ — KEPT (use --remove-wiki to delete)")
174
+ else:
175
+ print(" Not found.")
176
+
177
+ # Runtime artifacts
178
+ print("\n4. Runtime Artifacts:")
179
+ artifact_count = sum(1 for f in RUNTIME_ARTIFACTS if Path(f).exists())
180
+ if artifact_count:
181
+ for f in RUNTIME_ARTIFACTS:
182
+ if Path(f).exists():
183
+ print(f" {f}")
184
+ else:
185
+ print(" Nothing to remove.")
186
+
187
+ wiki_targeted = remove_wiki and wiki_dir.exists()
188
+ total = hooks_count + schema_count + (1 if wiki_targeted else 0) + artifact_count
189
+ if total == 0:
190
+ print("\nNothing to uninstall. Project is clean.")
191
+ return
192
+
193
+ if dry_run:
194
+ print(f"\nDry run complete. {total} item(s) would be affected.")
195
+ return
196
+
197
+ # ── 2. Confirm and execute ────────────────────────────────────────
198
+ print(f"\n{total} item(s) will be affected.")
199
+ if not _confirm("Proceed with uninstall?"):
200
+ print("Aborted.")
201
+ return
202
+
203
+ print()
204
+ removed_total = 0
205
+
206
+ # Execute removals
207
+ r = _remove_hooks()
208
+ removed_total += r
209
+
210
+ r = _clean_agent_schemas()
211
+ removed_total += r
212
+
213
+ if remove_wiki and wiki_dir.exists():
214
+ _remove_wiki_dir(wiki_dir)
215
+ removed_total += 1
216
+
217
+ r = _remove_runtime_artifacts()
218
+ removed_total += r
219
+
220
+ print(f"\nUninstall complete. {removed_total} item(s) removed.")
221
+ print("To uninstall the CLI itself: pip uninstall agent-wiki-cli")
@@ -0,0 +1,196 @@
1
+ """llm-wiki upgrade — refresh all framework-managed artifacts in place.
2
+
3
+ Replaces the uninstall → init → install-hook cycle with a single idempotent
4
+ command that:
5
+ 1. Replaces the agent constraint block with the latest version
6
+ 2. Ensures wiki directory structure is complete
7
+ 3. Reinstalls git hooks
8
+ 4. Optionally switches agents
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from ..config import AGENT_CHOICES, CLI_AGENTS, DEFAULT_WIKI_DIR, IDE_AGENTS, get_agent_config_path, read_config, validate_path, write_config
18
+ from ..services.io import read_md, write_md
19
+ from ..services.schema import (
20
+ ALL_SCHEMA_FILES,
21
+ CONSTRAINT_START,
22
+ SCHEMA_FILENAMES,
23
+ build_schema_content,
24
+ replace_schema_block,
25
+ strip_wiki_block,
26
+ )
27
+
28
+ # Re-use hook builders from hook_cmd to avoid duplication
29
+ from .hook_cmd import _build_ide_post_commit, _build_post_commit, _install_hook
30
+
31
+
32
+ def _read_agent_config(wiki_dir: str) -> str | None:
33
+ """Read the agent name persisted by `llm-wiki init`."""
34
+ config = read_config(wiki_dir)
35
+ agent = config.get("agent")
36
+ if agent and agent != "generic":
37
+ return agent
38
+ # Check if config file actually exists (defaults return "generic")
39
+ config_path = get_agent_config_path(wiki_dir)
40
+ if config_path.exists():
41
+ return agent
42
+ return None
43
+
44
+
45
+ def _resolve_agent(args, wiki_dir: str) -> str:
46
+ """Resolve agent: CLI --agent flag > persisted config > error."""
47
+ agent = getattr(args, "agent", None)
48
+ if agent:
49
+ return agent
50
+
51
+ stored = _read_agent_config(wiki_dir)
52
+ if stored:
53
+ return stored
54
+
55
+ print(
56
+ "Error: Cannot determine agent.\n"
57
+ f" No --agent flag provided and no config found at .git/.llm-wiki-agent\n\n"
58
+ " Either run `llm-wiki init --agent <agent>` first,\n"
59
+ " or pass --agent to this command:\n"
60
+ f" llm-wiki upgrade --agent <{'|'.join(AGENT_CHOICES)}>",
61
+ file=sys.stderr,
62
+ )
63
+ sys.exit(1)
64
+
65
+
66
+ def _upgrade_schema(agent: str, wiki_dir: str, old_agent: str | None, *, quality_hints: bool = True) -> str:
67
+ """Replace or migrate the agent schema constraint block.
68
+
69
+ Returns a summary message.
70
+ """
71
+ new_content = build_schema_content(agent, wiki_dir, quality_hints=quality_hints)
72
+ new_filename = SCHEMA_FILENAMES.get(agent)
73
+
74
+ if old_agent and old_agent != agent:
75
+ # Switching agents — clean old schema file first
76
+ old_filename = SCHEMA_FILENAMES.get(old_agent)
77
+ if old_filename:
78
+ old_path = Path(old_filename)
79
+ if old_path.exists():
80
+ existing = read_md(old_path)
81
+ if CONSTRAINT_START in existing:
82
+ stripped = strip_wiki_block(existing)
83
+ if stripped:
84
+ write_md(old_path, stripped)
85
+ print(f" Cleaned constraint block from: {old_filename}")
86
+ else:
87
+ old_path.unlink()
88
+ print(f" Removed: {old_filename} (only contained wiki constraints)")
89
+
90
+ # Write latest block to the target schema file
91
+ if new_filename:
92
+ schema_path = Path(new_filename)
93
+ replace_schema_block(schema_path, new_content)
94
+ return new_filename
95
+ return "(no schema file)"
96
+
97
+
98
+ def _upgrade_dirs(wiki_dir: str) -> int:
99
+ """Ensure all standard wiki subdirectories exist. Returns count of newly created dirs."""
100
+ base = Path(wiki_dir)
101
+ subdirs = ["entities", "modules", "workflows", "infrastructure"]
102
+ created = 0
103
+ for name in ["."] + subdirs:
104
+ d = base if name == "." else base / name
105
+ if not d.exists():
106
+ d.mkdir(parents=True, exist_ok=True)
107
+ created += 1
108
+ gitkeep = d / ".gitkeep"
109
+ if not gitkeep.exists():
110
+ gitkeep.touch()
111
+ # Ensure core files exist
112
+ index_path = base / "index.md"
113
+ if not index_path.exists():
114
+ write_md(index_path,
115
+ "# LLM Wiki Index\n\nCatalog of project modules and entities.\n\n"
116
+ "## Entities\n\n## Modules\n\n## Workflows\n\n## Infrastructure\n"
117
+ )
118
+ created += 1
119
+ log_path = base / "log.md"
120
+ if not log_path.exists():
121
+ write_md(log_path, "# Architectural Log\n\nAppend-only chronological log.\n\n")
122
+ created += 1
123
+ return created
124
+
125
+
126
+ def _upgrade_hooks(agent: str, wiki_dir: str, *, force: bool = False) -> None:
127
+ """Reinstall git hooks for the resolved agent."""
128
+ git_dir = Path(".git")
129
+ if not git_dir.exists():
130
+ print(" Skipped hooks (no .git directory)")
131
+ return
132
+
133
+ hooks_dir = git_dir / "hooks"
134
+ hooks_dir.mkdir(exist_ok=True)
135
+
136
+ if agent in IDE_AGENTS:
137
+ _install_hook(hooks_dir, "post-commit", _build_ide_post_commit(wiki_dir), force=force)
138
+ print(f" Hooks: IDE prompt-generation mode ({agent})")
139
+ else:
140
+ _install_hook(hooks_dir, "post-commit", _build_post_commit(agent, wiki_dir), force=force)
141
+ print(f" Hooks: CLI auto-sync mode ({agent})")
142
+
143
+
144
+ def run(args):
145
+ wiki_dir = getattr(args, "wiki_dir", DEFAULT_WIKI_DIR)
146
+ validate_path(wiki_dir, "--wiki-dir")
147
+
148
+ agent = _resolve_agent(args, wiki_dir)
149
+ old_agent = _read_agent_config(wiki_dir)
150
+ switching = old_agent and old_agent != agent
151
+
152
+ # Resolve quality_hints: CLI flag > stored config > default (True)
153
+ cli_hints = getattr(args, "quality_hints", None)
154
+ if cli_hints is not None:
155
+ quality_hints = cli_hints
156
+ else:
157
+ stored = read_config(wiki_dir)
158
+ quality_hints = stored.get("quality_hints", True)
159
+
160
+ print("LLM Wiki Upgrade")
161
+ print("=" * 40)
162
+
163
+ if switching:
164
+ print(f"\n Switching agent: {old_agent} → {agent}")
165
+ else:
166
+ print(f"\n Agent: {agent}")
167
+
168
+ # 1. Schema constraint block
169
+ print("\n1. Agent Schema:")
170
+ schema_file = _upgrade_schema(agent, wiki_dir, old_agent, quality_hints=quality_hints)
171
+ print(f" Updated: {schema_file}")
172
+
173
+ # 2. Wiki directories
174
+ print("\n2. Wiki Structure:")
175
+ new_dirs = _upgrade_dirs(wiki_dir)
176
+ if new_dirs:
177
+ print(f" Created {new_dirs} new entries in {wiki_dir}/")
178
+ else:
179
+ print(f" All directories present in {wiki_dir}/")
180
+
181
+ # 3. Git hooks
182
+ print("\n3. Git Hooks:")
183
+ _upgrade_hooks(agent, wiki_dir, force=getattr(args, "force", False))
184
+
185
+ # 4. Persist agent config
186
+ write_config(wiki_dir, {"agent": agent, "quality_hints": quality_hints})
187
+
188
+ # Warn if CLI agent executable missing
189
+ executable = CLI_AGENTS.get(agent)
190
+ if executable and not shutil.which(executable):
191
+ print(
192
+ f"\nWarning: '{executable}' not found on PATH.\n"
193
+ f" Background auto-sync won't work until '{executable}' is installed."
194
+ )
195
+
196
+ print("\nUpgrade complete.")