|
| 1 | +"""Shared entity I/O utilities for the Kaizen plugin. |
| 2 | +
|
| 3 | +Handles reading and writing entities as flat markdown files with YAML |
| 4 | +frontmatter, organized in type-nested directories. |
| 5 | +""" |
| 6 | + |
| 7 | +import datetime |
| 8 | +import getpass |
| 9 | +import os |
| 10 | +import re |
| 11 | +import tempfile |
| 12 | +from pathlib import Path |
| 13 | + |
| 14 | + |
| 15 | +# --------------------------------------------------------------------------- |
| 16 | +# Logging |
| 17 | +# --------------------------------------------------------------------------- |
| 18 | + |
| 19 | + |
| 20 | +def _get_log_dir(): |
| 21 | + """Get user-scoped log directory with restrictive permissions.""" |
| 22 | + try: |
| 23 | + uid = os.getuid() |
| 24 | + except AttributeError: |
| 25 | + uid = getpass.getuser() |
| 26 | + log_dir = os.path.join(tempfile.gettempdir(), f"kaizen-{uid}") |
| 27 | + os.makedirs(log_dir, mode=0o700, exist_ok=True) |
| 28 | + return log_dir |
| 29 | + |
| 30 | + |
| 31 | +_LOG_FILE = os.path.join(_get_log_dir(), "kaizen-plugin.log") |
| 32 | + |
| 33 | + |
| 34 | +def log(component, message): |
| 35 | + """Append a timestamped message to the shared log file. |
| 36 | +
|
| 37 | + Args: |
| 38 | + component: Short label like "retrieve" or "save". |
| 39 | + message: The log line. |
| 40 | + """ |
| 41 | + if not os.environ.get("KAIZEN_DEBUG"): |
| 42 | + return |
| 43 | + try: |
| 44 | + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") |
| 45 | + with open(_LOG_FILE, "a", encoding="utf-8") as f: |
| 46 | + f.write(f"[{timestamp}] [{component}] {message}\n") |
| 47 | + except OSError: |
| 48 | + pass |
| 49 | + |
| 50 | + |
| 51 | +# --------------------------------------------------------------------------- |
| 52 | +# Directory discovery |
| 53 | +# --------------------------------------------------------------------------- |
| 54 | + |
| 55 | + |
| 56 | +def find_entities_dir(): |
| 57 | + """Locate the entities directory. |
| 58 | +
|
| 59 | + Search order: |
| 60 | + 1. ``KAIZEN_ENTITIES_DIR`` env var (authoritative, no fallback) |
| 61 | + 2. ``{CLAUDE_PROJECT_ROOT}/.kaizen/entities/`` |
| 62 | + 3. ``.kaizen/entities/`` (cwd) |
| 63 | +
|
| 64 | + Returns: |
| 65 | + Path to the directory if it exists, else ``None``. |
| 66 | + """ |
| 67 | + env_dir = os.environ.get("KAIZEN_ENTITIES_DIR") |
| 68 | + if env_dir: |
| 69 | + p = Path(env_dir) |
| 70 | + return p if p.is_dir() else None |
| 71 | + |
| 72 | + project_root = os.environ.get("CLAUDE_PROJECT_ROOT") |
| 73 | + candidates = [] |
| 74 | + if project_root: |
| 75 | + candidates.append(Path(project_root) / ".kaizen" / "entities") |
| 76 | + candidates.append(Path(".kaizen") / "entities") |
| 77 | + |
| 78 | + for c in candidates: |
| 79 | + if c.is_dir(): |
| 80 | + return c |
| 81 | + return None |
| 82 | + |
| 83 | + |
| 84 | +def get_default_entities_dir(): |
| 85 | + """Return (and create) the default entities directory. |
| 86 | +
|
| 87 | + Prefers ``{CLAUDE_PROJECT_ROOT}/.kaizen/entities/``, falls back to |
| 88 | + ``.kaizen/entities/``. |
| 89 | + """ |
| 90 | + project_root = os.environ.get("CLAUDE_PROJECT_ROOT", "") |
| 91 | + if project_root: |
| 92 | + base = Path(project_root) / ".kaizen" / "entities" |
| 93 | + else: |
| 94 | + base = Path(".kaizen") / "entities" |
| 95 | + base.mkdir(parents=True, exist_ok=True) |
| 96 | + return base.resolve() |
| 97 | + |
| 98 | + |
| 99 | +# --------------------------------------------------------------------------- |
| 100 | +# Slugify / filename helpers |
| 101 | +# --------------------------------------------------------------------------- |
| 102 | + |
| 103 | + |
| 104 | +def slugify(text, max_length=60): |
| 105 | + """Convert *text* to a filesystem-safe slug. |
| 106 | +
|
| 107 | + >>> slugify("Use temp files for JSON transfer!") |
| 108 | + 'use-temp-files-for-json-transfer' |
| 109 | + """ |
| 110 | + text = text.lower() |
| 111 | + text = re.sub(r"[^a-z0-9]+", "-", text) |
| 112 | + text = text.strip("-") |
| 113 | + # Truncate at max_length, but don't break in the middle of a word |
| 114 | + if len(text) > max_length: |
| 115 | + text = text[:max_length].rsplit("-", 1)[0] |
| 116 | + return text or "entity" |
| 117 | + |
| 118 | + |
| 119 | +def unique_filename(directory, slug): |
| 120 | + """Return a Path that doesn't collide with existing files in *directory*. |
| 121 | +
|
| 122 | + Tries ``slug.md``, then ``slug-2.md``, ``slug-3.md``, etc. |
| 123 | + """ |
| 124 | + directory = Path(directory) |
| 125 | + candidate = directory / f"{slug}.md" |
| 126 | + if not candidate.exists(): |
| 127 | + return candidate |
| 128 | + n = 2 |
| 129 | + while True: |
| 130 | + candidate = directory / f"{slug}-{n}.md" |
| 131 | + if not candidate.exists(): |
| 132 | + return candidate |
| 133 | + n += 1 |
| 134 | + |
| 135 | + |
| 136 | +# --------------------------------------------------------------------------- |
| 137 | +# Markdown <-> dict conversion |
| 138 | +# --------------------------------------------------------------------------- |
| 139 | + |
| 140 | +_FRONTMATTER_KEYS = ("type", "trigger") |
| 141 | + |
| 142 | + |
| 143 | +def entity_to_markdown(entity): |
| 144 | + """Serialize an entity dict to markdown with YAML frontmatter. |
| 145 | +
|
| 146 | + Args: |
| 147 | + entity: dict with keys ``content``, and optionally ``type``, |
| 148 | + ``trigger``, ``rationale``. |
| 149 | +
|
| 150 | + Returns: |
| 151 | + A string suitable for writing to a ``.md`` file. |
| 152 | + """ |
| 153 | + lines = ["---"] |
| 154 | + for key in _FRONTMATTER_KEYS: |
| 155 | + val = entity.get(key) |
| 156 | + if val: |
| 157 | + lines.append(f"{key}: {val}") |
| 158 | + lines.append("---") |
| 159 | + lines.append("") |
| 160 | + |
| 161 | + content = entity.get("content", "") |
| 162 | + lines.append(content) |
| 163 | + |
| 164 | + rationale = entity.get("rationale") |
| 165 | + if rationale: |
| 166 | + lines.append("") |
| 167 | + lines.append("## Rationale") |
| 168 | + lines.append("") |
| 169 | + lines.append(rationale) |
| 170 | + |
| 171 | + lines.append("") |
| 172 | + return "\n".join(lines) |
| 173 | + |
| 174 | + |
| 175 | +def markdown_to_entity(path): |
| 176 | + """Parse a markdown entity file back into a dict. |
| 177 | +
|
| 178 | + Handles YAML frontmatter with simple ``key: value`` lines (no nested |
| 179 | + structures, no PyYAML dependency). |
| 180 | +
|
| 181 | + Returns: |
| 182 | + dict with ``content``, ``type``, ``trigger``, ``rationale`` keys. |
| 183 | + """ |
| 184 | + path = Path(path) |
| 185 | + text = path.read_text(encoding="utf-8") |
| 186 | + |
| 187 | + entity = {} |
| 188 | + |
| 189 | + # Split frontmatter |
| 190 | + if text.startswith("---"): |
| 191 | + parts = text.split("---", 2) |
| 192 | + if len(parts) >= 3: |
| 193 | + frontmatter = parts[1].strip() |
| 194 | + body = parts[2] |
| 195 | + for line in frontmatter.splitlines(): |
| 196 | + line = line.strip() |
| 197 | + if not line: |
| 198 | + continue |
| 199 | + key, _, value = line.partition(":") |
| 200 | + key = key.strip() |
| 201 | + value = value.strip() |
| 202 | + if key and value: |
| 203 | + entity[key] = value |
| 204 | + else: |
| 205 | + body = text |
| 206 | + else: |
| 207 | + body = text |
| 208 | + |
| 209 | + # Split body into content and rationale |
| 210 | + body = body.strip() |
| 211 | + m = re.search(r"^## Rationale", body, re.MULTILINE) |
| 212 | + if m: |
| 213 | + content = body[: m.start()].strip() |
| 214 | + rationale = body[m.end() :].strip() |
| 215 | + if rationale: |
| 216 | + entity["rationale"] = rationale |
| 217 | + else: |
| 218 | + content = body |
| 219 | + |
| 220 | + if content: |
| 221 | + entity["content"] = content |
| 222 | + |
| 223 | + return entity |
| 224 | + |
| 225 | + |
| 226 | +# --------------------------------------------------------------------------- |
| 227 | +# Bulk load / write |
| 228 | +# --------------------------------------------------------------------------- |
| 229 | + |
| 230 | + |
| 231 | +def load_all_entities(entities_dir): |
| 232 | + """Glob ``**/*.md`` under *entities_dir* and parse each file. |
| 233 | +
|
| 234 | + Returns: |
| 235 | + list of entity dicts. |
| 236 | + """ |
| 237 | + entities_dir = Path(entities_dir) |
| 238 | + entities = [] |
| 239 | + for md in sorted(entities_dir.glob("**/*.md")): |
| 240 | + try: |
| 241 | + entity = markdown_to_entity(md) |
| 242 | + if entity.get("content"): |
| 243 | + entities.append(entity) |
| 244 | + except OSError: |
| 245 | + pass |
| 246 | + return entities |
| 247 | + |
| 248 | + |
| 249 | +def write_entity_file(directory, entity): |
| 250 | + """Write a single entity as a markdown file under *directory*. |
| 251 | +
|
| 252 | + The file is placed in a ``{type}/`` subdirectory. Uses atomic |
| 253 | + write (write to ``.tmp``, then ``os.rename``). |
| 254 | +
|
| 255 | + Returns: |
| 256 | + Path to the written file. |
| 257 | + """ |
| 258 | + entity_type = entity.get("type", "general") |
| 259 | + if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", entity_type): |
| 260 | + entity_type = "general" |
| 261 | + type_dir = Path(directory) / entity_type |
| 262 | + type_dir.mkdir(parents=True, exist_ok=True) |
| 263 | + |
| 264 | + slug = slugify(entity.get("content", "entity")) |
| 265 | + content = entity_to_markdown(entity) |
| 266 | + |
| 267 | + # Write to a unique temp file first (avoids predictable .tmp collisions) |
| 268 | + fd, tmp_path = tempfile.mkstemp(dir=type_dir, suffix=".tmp", prefix=slug) |
| 269 | + try: |
| 270 | + os.write(fd, content.encode("utf-8")) |
| 271 | + os.close(fd) |
| 272 | + fd = None |
| 273 | + |
| 274 | + # Atomically claim the target using O_EXCL to detect races |
| 275 | + target = unique_filename(type_dir, slug) |
| 276 | + try: |
| 277 | + claim_fd = os.open(str(target), os.O_CREAT | os.O_EXCL | os.O_WRONLY) |
| 278 | + os.close(claim_fd) |
| 279 | + except FileExistsError: |
| 280 | + # Another writer beat us — re-discover a free name |
| 281 | + target = unique_filename(type_dir, slug) |
| 282 | + |
| 283 | + os.rename(tmp_path, target) |
| 284 | + return target |
| 285 | + except BaseException: |
| 286 | + if fd is not None: |
| 287 | + os.close(fd) |
| 288 | + if os.path.exists(tmp_path): |
| 289 | + os.unlink(tmp_path) |
| 290 | + raise |
0 commit comments