invar-tools 1.10.0__py3-none-any.whl → 1.11.0__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.
@@ -0,0 +1,459 @@
1
+ """
2
+ Shell layer for document tools.
3
+
4
+ DX-76: File I/O operations for structured document queries.
5
+ Returns Result[T, E] for error handling.
6
+ """
7
+
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from returns.result import Failure, Result, Success
12
+
13
+ from invar.core.doc_edit import (
14
+ delete_section as core_delete_section,
15
+ )
16
+ from invar.core.doc_edit import (
17
+ insert_section as core_insert_section,
18
+ )
19
+ from invar.core.doc_edit import (
20
+ replace_section as core_replace_section,
21
+ )
22
+ from invar.core.doc_parser import (
23
+ DocumentToc,
24
+ Section,
25
+ extract_content,
26
+ find_section,
27
+ parse_toc,
28
+ )
29
+
30
+
31
+ # @shell_complexity: Multiple I/O error types (OSError, IsADirectoryError, etc.) require separate handling
32
+ def read_toc(path: Path) -> Result[DocumentToc, str]:
33
+ """Read and parse document table of contents.
34
+
35
+ Examples:
36
+ >>> from pathlib import Path
37
+ >>> import tempfile
38
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
39
+ ... _ = f.write("# Hello\\n\\nWorld")
40
+ ... p = Path(f.name)
41
+ >>> result = read_toc(p)
42
+ >>> isinstance(result, Success)
43
+ True
44
+ >>> result.unwrap().sections[0].title
45
+ 'Hello'
46
+ >>> p.unlink()
47
+ """
48
+ try:
49
+ content = path.read_text(encoding="utf-8")
50
+ toc = parse_toc(content)
51
+ return Success(toc)
52
+ except FileNotFoundError:
53
+ return Failure(f"File not found: {path}")
54
+ except IsADirectoryError:
55
+ return Failure(f"Path is a directory, not a file: {path}")
56
+ except PermissionError:
57
+ return Failure(f"Permission denied: {path}")
58
+ except UnicodeDecodeError:
59
+ return Failure(f"Failed to decode file as UTF-8: {path}")
60
+ except OSError as e:
61
+ return Failure(f"OS error reading {path}: {e}")
62
+
63
+
64
+ # @shell_complexity: Multiple I/O error types require separate handling
65
+ def read_section(
66
+ path: Path, section_path: str, include_children: bool = True
67
+ ) -> Result[str, str]:
68
+ """Read a specific section from a document.
69
+
70
+ Args:
71
+ path: Path to markdown file
72
+ section_path: Section path (slug, fuzzy, index, or line anchor)
73
+ include_children: If True, include child sections in output
74
+
75
+ Returns:
76
+ Result containing section content or error message
77
+
78
+ Examples:
79
+ >>> from pathlib import Path
80
+ >>> import tempfile
81
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
82
+ ... _ = f.write("# Title\\n\\nContent here")
83
+ ... p = Path(f.name)
84
+ >>> result = read_section(p, "title")
85
+ >>> isinstance(result, Success)
86
+ True
87
+ >>> "Title" in result.unwrap()
88
+ True
89
+ >>> p.unlink()
90
+ """
91
+ try:
92
+ content = path.read_text(encoding="utf-8")
93
+ except FileNotFoundError:
94
+ return Failure(f"File not found: {path}")
95
+ except IsADirectoryError:
96
+ return Failure(f"Path is a directory, not a file: {path}")
97
+ except PermissionError:
98
+ return Failure(f"Permission denied: {path}")
99
+ except UnicodeDecodeError:
100
+ return Failure(f"Failed to decode file as UTF-8: {path}")
101
+ except OSError as e:
102
+ return Failure(f"OS error reading {path}: {e}")
103
+
104
+ toc = parse_toc(content)
105
+ section = find_section(toc.sections, section_path)
106
+
107
+ if section is None:
108
+ return Failure(f"Section not found: {section_path}")
109
+
110
+ extracted = extract_content(content, section, include_children=include_children)
111
+ return Success(extracted)
112
+
113
+
114
+ # @shell_complexity: Multiple I/O error types + batch section iteration
115
+ def read_sections_batch(
116
+ path: Path,
117
+ section_paths: list[str],
118
+ include_children: bool = True
119
+ ) -> Result[list[dict[str, str]], str]:
120
+ """
121
+ Read multiple sections from a document in one operation.
122
+
123
+ Returns a list of dicts, each containing 'path' and 'content' keys.
124
+ If any section fails to read, returns Failure with error message.
125
+
126
+ Args:
127
+ path: Path to markdown file
128
+ section_paths: List of section paths (slug, fuzzy, index, or line anchor)
129
+ include_children: If True, include child sections in output
130
+
131
+ Returns:
132
+ Result containing list of section dicts or error message
133
+
134
+ Examples:
135
+ >>> from pathlib import Path
136
+ >>> import tempfile
137
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
138
+ ... _ = f.write("# A\\n\\nContent A\\n\\n# B\\n\\nContent B\\n\\n# C\\n\\nContent C")
139
+ ... p = Path(f.name)
140
+ >>> result = read_sections_batch(p, ["a", "b"])
141
+ >>> isinstance(result, Success)
142
+ True
143
+ >>> sections = result.unwrap()
144
+ >>> len(sections)
145
+ 2
146
+ >>> sections[0]['path']
147
+ 'a'
148
+ >>> "Content A" in sections[0]['content']
149
+ True
150
+ >>> p.unlink()
151
+ """
152
+ # Read file once
153
+ try:
154
+ content = path.read_text(encoding="utf-8")
155
+ except FileNotFoundError:
156
+ return Failure(f"File not found: {path}")
157
+ except IsADirectoryError:
158
+ return Failure(f"Path is a directory, not a file: {path}")
159
+ except PermissionError:
160
+ return Failure(f"Permission denied: {path}")
161
+ except UnicodeDecodeError:
162
+ return Failure(f"Failed to decode file as UTF-8: {path}")
163
+ except OSError as e:
164
+ return Failure(f"OS error reading {path}: {e}")
165
+
166
+ # Parse TOC once
167
+ toc = parse_toc(content)
168
+
169
+ # Extract all requested sections
170
+ results = []
171
+ for section_path in section_paths:
172
+ section = find_section(toc.sections, section_path)
173
+
174
+ if section is None:
175
+ return Failure(f"Section not found: {section_path}")
176
+
177
+ extracted = extract_content(content, section, include_children=include_children)
178
+ results.append({
179
+ "path": section_path,
180
+ "content": extracted
181
+ })
182
+
183
+ return Success(results)
184
+
185
+
186
+ # @shell_complexity: Pattern matching + content filtering orchestration
187
+ def find_sections(
188
+ path: Path,
189
+ pattern: str,
190
+ content_pattern: str | None = None,
191
+ level: int | None = None,
192
+ ) -> Result[list[Section], str]:
193
+ """Find sections matching a pattern.
194
+
195
+ Args:
196
+ path: Path to markdown file
197
+ pattern: Title pattern (glob-style)
198
+ content_pattern: Optional content search pattern
199
+ level: Optional filter by heading level (1-6)
200
+
201
+ Returns:
202
+ Result containing list of matching sections
203
+
204
+ Examples:
205
+ >>> from pathlib import Path
206
+ >>> import tempfile
207
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
208
+ ... _ = f.write("# Intro\\n\\n## Overview\\n\\n# Summary")
209
+ ... p = Path(f.name)
210
+ >>> result = find_sections(p, "*")
211
+ >>> isinstance(result, Success)
212
+ True
213
+ >>> len(result.unwrap()) >= 2
214
+ True
215
+ >>> p.unlink()
216
+ """
217
+ try:
218
+ content = path.read_text(encoding="utf-8")
219
+ except FileNotFoundError:
220
+ return Failure(f"File not found: {path}")
221
+ except IsADirectoryError:
222
+ return Failure(f"Path is a directory, not a file: {path}")
223
+ except PermissionError:
224
+ return Failure(f"Permission denied: {path}")
225
+ except UnicodeDecodeError:
226
+ return Failure(f"Failed to decode file as UTF-8: {path}")
227
+ except OSError as e:
228
+ return Failure(f"OS error reading {path}: {e}")
229
+
230
+ toc = parse_toc(content)
231
+
232
+ # Collect all sections recursively
233
+ def collect_all(sections: list[Section]) -> list[Section]:
234
+ result: list[Section] = []
235
+ for s in sections:
236
+ result.append(s)
237
+ result.extend(collect_all(s.children))
238
+ return result
239
+
240
+ all_sections = collect_all(toc.sections)
241
+
242
+ # Filter by pattern
243
+ import fnmatch
244
+
245
+ matches = [s for s in all_sections if fnmatch.fnmatch(s.title.lower(), pattern.lower())]
246
+
247
+ # Filter by level if specified
248
+ if level is not None:
249
+ matches = [s for s in matches if s.level == level]
250
+
251
+ # Filter by content if specified
252
+ if content_pattern:
253
+ content_matches = []
254
+ for s in matches:
255
+ section_content = extract_content(content, s)
256
+ if content_pattern.lower() in section_content.lower():
257
+ content_matches.append(s)
258
+ matches = content_matches
259
+
260
+ return Success(matches)
261
+
262
+
263
+ # DX-76 Phase A-2: Extended editing tools
264
+
265
+ # @shell_complexity: Read + find + edit + write orchestration
266
+ def replace_section_content(
267
+ path: Path,
268
+ section_path: str,
269
+ new_content: str,
270
+ keep_heading: bool = True,
271
+ ) -> Result[dict[str, str | int], str]:
272
+ """Replace a section's content in a document.
273
+
274
+ Args:
275
+ path: Path to markdown file
276
+ section_path: Section path (slug, fuzzy, index, or line anchor)
277
+ new_content: New content to replace the section with
278
+ keep_heading: If True, preserve the original heading line
279
+
280
+ Returns:
281
+ Result containing info about the replacement or error message
282
+
283
+ Examples:
284
+ >>> from pathlib import Path
285
+ >>> import tempfile
286
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
287
+ ... _ = f.write("# Title\\n\\nOld content\\n\\n# Next")
288
+ ... p = Path(f.name)
289
+ >>> result = replace_section_content(p, "title", "New content\\n")
290
+ >>> isinstance(result, Success)
291
+ True
292
+ >>> "New content" in p.read_text()
293
+ True
294
+ >>> p.unlink()
295
+ """
296
+ try:
297
+ content = path.read_text(encoding="utf-8")
298
+ except FileNotFoundError:
299
+ return Failure(f"File not found: {path}")
300
+ except IsADirectoryError:
301
+ return Failure(f"Path is a directory, not a file: {path}")
302
+ except PermissionError:
303
+ return Failure(f"Permission denied: {path}")
304
+ except UnicodeDecodeError:
305
+ return Failure(f"Failed to decode file as UTF-8: {path}")
306
+ except OSError as e:
307
+ return Failure(f"OS error reading {path}: {e}")
308
+
309
+ toc = parse_toc(content)
310
+ section = find_section(toc.sections, section_path)
311
+
312
+ if section is None:
313
+ return Failure(f"Section not found: {section_path}")
314
+
315
+ old_content = extract_content(content, section)
316
+ new_source = core_replace_section(content, section, new_content, keep_heading)
317
+
318
+ try:
319
+ path.write_text(new_source, encoding="utf-8")
320
+ except PermissionError:
321
+ return Failure(f"Permission denied: {path}")
322
+ except OSError as e:
323
+ return Failure(f"OS error writing {path}: {e}")
324
+
325
+ return Success({
326
+ "old_content": old_content,
327
+ "new_line_count": len(new_source.split("\n")),
328
+ })
329
+
330
+
331
+ # @shell_complexity: Read + find + insert + write orchestration
332
+ def insert_section_content(
333
+ path: Path,
334
+ anchor_path: str,
335
+ content: str,
336
+ position: Literal["before", "after", "first_child", "last_child"] = "after",
337
+ ) -> Result[dict[str, str | int], str]:
338
+ """Insert new content relative to a section.
339
+
340
+ Args:
341
+ path: Path to markdown file
342
+ anchor_path: Section path for the anchor
343
+ content: Content to insert (should include heading if adding a section)
344
+ position: Where to insert relative to anchor
345
+
346
+ Returns:
347
+ Result containing info about the insertion or error message
348
+
349
+ Examples:
350
+ >>> from pathlib import Path
351
+ >>> import tempfile
352
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
353
+ ... _ = f.write("# Title\\n\\nContent")
354
+ ... p = Path(f.name)
355
+ >>> result = insert_section_content(p, "title", "\\n## Subsection\\n\\nNew text", "after")
356
+ >>> isinstance(result, Success)
357
+ True
358
+ >>> "## Subsection" in p.read_text()
359
+ True
360
+ >>> p.unlink()
361
+ """
362
+ try:
363
+ source = path.read_text(encoding="utf-8")
364
+ except FileNotFoundError:
365
+ return Failure(f"File not found: {path}")
366
+ except IsADirectoryError:
367
+ return Failure(f"Path is a directory, not a file: {path}")
368
+ except PermissionError:
369
+ return Failure(f"Permission denied: {path}")
370
+ except UnicodeDecodeError:
371
+ return Failure(f"Failed to decode file as UTF-8: {path}")
372
+ except OSError as e:
373
+ return Failure(f"OS error reading {path}: {e}")
374
+
375
+ toc = parse_toc(source)
376
+ anchor = find_section(toc.sections, anchor_path)
377
+
378
+ if anchor is None:
379
+ return Failure(f"Section not found: {anchor_path}")
380
+
381
+ new_source = core_insert_section(source, anchor, content, position)
382
+
383
+ try:
384
+ path.write_text(new_source, encoding="utf-8")
385
+ except PermissionError:
386
+ return Failure(f"Permission denied: {path}")
387
+ except OSError as e:
388
+ return Failure(f"OS error writing {path}: {e}")
389
+
390
+ return Success({
391
+ "inserted_at": anchor.line_end if position == "after" else anchor.line_start,
392
+ "new_line_count": len(new_source.split("\n")),
393
+ })
394
+
395
+
396
+ # @shell_complexity: Read + find + delete + write orchestration
397
+ def delete_section_content(
398
+ path: Path,
399
+ section_path: str,
400
+ include_children: bool = True,
401
+ ) -> Result[dict[str, str | int], str]:
402
+ """Delete a section from a document.
403
+
404
+ Args:
405
+ path: Path to markdown file
406
+ section_path: Section path (slug, fuzzy, index, or line anchor)
407
+ include_children: If True, delete child sections too
408
+
409
+ Returns:
410
+ Result containing info about the deletion or error message
411
+
412
+ Examples:
413
+ >>> from pathlib import Path
414
+ >>> import tempfile
415
+ >>> with tempfile.NamedTemporaryFile(mode='w', suffix='.md', delete=False) as f:
416
+ ... _ = f.write("# Keep\\n\\n# Delete\\n\\nContent\\n\\n# Also Keep")
417
+ ... p = Path(f.name)
418
+ >>> result = delete_section_content(p, "delete")
419
+ >>> isinstance(result, Success)
420
+ True
421
+ >>> "# Delete" not in p.read_text()
422
+ True
423
+ >>> p.unlink()
424
+ """
425
+ try:
426
+ source = path.read_text(encoding="utf-8")
427
+ except FileNotFoundError:
428
+ return Failure(f"File not found: {path}")
429
+ except IsADirectoryError:
430
+ return Failure(f"Path is a directory, not a file: {path}")
431
+ except PermissionError:
432
+ return Failure(f"Permission denied: {path}")
433
+ except UnicodeDecodeError:
434
+ return Failure(f"Failed to decode file as UTF-8: {path}")
435
+ except OSError as e:
436
+ return Failure(f"OS error reading {path}: {e}")
437
+
438
+ toc = parse_toc(source)
439
+ section = find_section(toc.sections, section_path)
440
+
441
+ if section is None:
442
+ return Failure(f"Section not found: {section_path}")
443
+
444
+ deleted_content = extract_content(source, section, include_children=include_children)
445
+ new_source = core_delete_section(source, section, include_children=include_children)
446
+
447
+ try:
448
+ path.write_text(new_source, encoding="utf-8")
449
+ except PermissionError:
450
+ return Failure(f"Permission denied: {path}")
451
+ except OSError as e:
452
+ return Failure(f"OS error writing {path}: {e}")
453
+
454
+ return Success({
455
+ "deleted_content": deleted_content,
456
+ "deleted_line_start": section.line_start,
457
+ "deleted_line_end": section.line_end,
458
+ "new_line_count": len(new_source.split("\n")),
459
+ })
invar/shell/fs.py CHANGED
@@ -21,7 +21,18 @@ if TYPE_CHECKING:
21
21
 
22
22
  # @shell_orchestration: Helper for file discovery, co-located with I/O functions
23
23
  def _is_excluded(relative_str: str, exclude_patterns: list[str]) -> bool:
24
- """Check if a relative path should be excluded."""
24
+ """Check if a relative path should be excluded.
25
+
26
+ Matches patterns as whole path components, not as prefixes:
27
+ - "dist" matches "dist", "dist/file.py", "src/dist/file.py"
28
+ - "dist" does NOT match "distribute" or "mydist" (prefix/suffix matching)
29
+
30
+ Note: A file literally named "some/dist" (not a directory) would not match
31
+ pattern "dist" - this is intentional as patterns target directory names.
32
+
33
+ Unix path assumption: Uses "/" separator. On Windows, paths should be
34
+ normalized before calling (Python's pathlib handles this).
35
+ """
25
36
  for pattern in exclude_patterns:
26
37
  # Match whole path component, not prefix
27
38
  if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
@@ -80,19 +91,9 @@ def discover_typescript_files(
80
91
 
81
92
  for ext in ("*.ts", "*.tsx"):
82
93
  for ts_file in project_root.rglob(ext):
83
- # Check exclusions
84
- relative = ts_file.relative_to(project_root)
85
- relative_str = str(relative)
86
-
87
- excluded = False
88
- for pattern in all_excludes:
89
- # Match whole path component, not prefix
90
- # e.g., "dist" should exclude "dist/file.ts" but NOT "dist_backup/file.ts"
91
- if relative_str == pattern or relative_str.startswith(pattern + "/") or f"/{pattern}/" in f"/{relative_str}":
92
- excluded = True
93
- break
94
-
95
- if not excluded:
94
+ # Check exclusions using shared helper (DX review: deduplicate)
95
+ relative_str = str(ts_file.relative_to(project_root))
96
+ if not _is_excluded(relative_str, all_excludes):
96
97
  yield ts_file
97
98
 
98
99
 
@@ -164,6 +164,9 @@ def _verify_single_file(
164
164
  "compile() arg 1 must be", # ast.parse limitation
165
165
  "ValueError: wrong parameter order", # CrossHair signature bug
166
166
  "ValueError: cannot determine truth", # Symbolic execution limit
167
+ "RecursionError:", # Infinite recursion in repr code
168
+ "maximum recursion depth exceeded", # Stack overflow
169
+ "format_boundargs", # CrossHair repr formatting bug
167
170
  ]
168
171
  is_execution_error = any(err in output for err in execution_errors)
169
172
 
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import contextlib
13
13
  import json
14
+ import re
14
15
  import subprocess
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
@@ -265,9 +266,9 @@ def _generate_fix_suggestions(violations: list[TypeScriptViolation]) -> list[dic
265
266
 
266
267
  # Customize code based on rule
267
268
  if rule == "@invar/require-schema-validation":
268
- # Extract param name from message
269
- import re as re_module
270
- param_match = re_module.search(r'"(\w+)"', v.message)
269
+ # Extract param name from message (fragile: depends on ESLint message format)
270
+ # Falls back to "input" if extraction fails - user can adjust in fix suggestion
271
+ param_match = re.search(r'"(\w+)"', v.message)
271
272
  param = param_match.group(1) if param_match else "input"
272
273
  code = code.replace("{param}", param)
273
274
  elif rule == "@invar/shell-result-type":
@@ -298,16 +299,20 @@ def _generate_fix_suggestions(violations: list[TypeScriptViolation]) -> list[dic
298
299
  return fixes
299
300
 
300
301
 
301
- def check_tool_available(tool: str, check_args: list[str]) -> bool:
302
+ def _check_tool_available(tool: str, check_args: list[str]) -> bool:
302
303
  """Check if a tool is available in PATH.
303
304
 
304
305
  Args:
305
- tool: Tool name (e.g., "npx", "tsc")
306
+ tool: Tool name (e.g., "npx", "tsc") - must be alphanumeric/dash/underscore
306
307
  check_args: Arguments for version check
307
308
 
308
309
  Returns:
309
310
  True if tool is available and responds to check.
310
311
  """
312
+ # Security: validate tool name to prevent command injection
313
+ if not tool or not all(c.isalnum() or c in "-_" for c in tool):
314
+ return False
315
+
311
316
  try:
312
317
  result = subprocess.run(
313
318
  [tool, *check_args],
@@ -637,8 +642,6 @@ def _parse_tsc_line(line: str) -> TypeScriptViolation | None:
637
642
  >>> v.rule if v else None
638
643
  'TS2322'
639
644
  """
640
- import re
641
-
642
645
  # Pattern: file(line,col): severity TSxxxx: message
643
646
  pattern = r"^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.+)$"
644
647
  match = re.match(pattern, line)
@@ -812,9 +815,9 @@ def run_typescript_guard(
812
815
  result = TypeScriptGuardResult(status="passed")
813
816
 
814
817
  # Check tool availability
815
- result.tsc_available = check_tool_available("npx", ["tsc", "--version"])
816
- result.eslint_available = check_tool_available("npx", ["eslint", "--version"])
817
- result.vitest_available = check_tool_available("npx", ["vitest", "--version"])
818
+ result.tsc_available = _check_tool_available("npx", ["tsc", "--version"])
819
+ result.eslint_available = _check_tool_available("npx", ["eslint", "--version"])
820
+ result.vitest_available = _check_tool_available("npx", ["vitest", "--version"])
818
821
 
819
822
  all_violations: list[TypeScriptViolation] = []
820
823
 
@@ -11,6 +11,7 @@ DX-71: Simplified to idempotent `add` command with region merge.
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import re
14
15
  import shutil
15
16
  from dataclasses import dataclass
16
17
  from pathlib import Path
@@ -35,12 +36,15 @@ CORE_SKILLS = {"develop", "review", "investigate", "propose", "guard", "audit"}
35
36
 
36
37
  # @shell_orchestration: Validation helper used only by shell add_skill/remove_skill
37
38
  def _is_valid_skill_name(name: str) -> bool:
38
- """Validate skill name to prevent path traversal attacks."""
39
- # Block path traversal characters
40
- if ".." in name or "/" in name or "\\" in name:
39
+ """Validate skill name to prevent path traversal and filesystem attacks."""
40
+ # Block path traversal characters and null bytes
41
+ if ".." in name or "/" in name or "\\" in name or "\x00" in name:
41
42
  return False
42
- # Must be non-empty and not start with dot or underscore
43
- return bool(name) and not name.startswith(".") and not name.startswith("_")
43
+ # Block special names that could cause issues
44
+ if name in (".", ""):
45
+ return False
46
+ # Must not start with dot or underscore
47
+ return not name.startswith(".") and not name.startswith("_")
44
48
 
45
49
 
46
50
  def _merge_md_file(src: Path, dst: Path) -> tuple[bool, str]:
@@ -74,10 +78,10 @@ def _merge_md_file(src: Path, dst: Path) -> tuple[bool, str]:
74
78
  shutil.copy2(src, dst)
75
79
  return False, "Updated"
76
80
 
77
- except Exception:
78
- # On parse error, preserve existing file - don't silently lose user data
79
- # Return warning message so caller can inform user
80
- return False, "Skipped (merge failed, existing file preserved)"
81
+ except (OSError, UnicodeDecodeError, ValueError, KeyError) as e:
82
+ # On I/O or parse error, preserve existing file - don't silently lose user data
83
+ # Include error details for debugging
84
+ return False, f"Skipped (merge failed: {type(e).__name__}: {e})"
81
85
 
82
86
 
83
87
  @dataclass
@@ -109,7 +113,7 @@ def load_registry() -> Result[dict, str]:
109
113
  content = registry_path.read_text()
110
114
  data = yaml.safe_load(content)
111
115
  return Success(data)
112
- except Exception as e:
116
+ except (yaml.YAMLError, OSError, UnicodeDecodeError) as e:
113
117
  return Failure(f"Failed to parse registry: {e}")
114
118
 
115
119
 
@@ -245,7 +249,7 @@ def add_skill(
245
249
  result_msg = "updated" if is_update else "installed"
246
250
  return Success(f"Skill '{skill_name}' {result_msg} successfully")
247
251
 
248
- except Exception as e:
252
+ except (OSError, shutil.Error) as e:
249
253
  # Clean up on failure (only for fresh install)
250
254
  # M3 note: Updates that fail mid-way may leave directory in partial state.
251
255
  # This is acceptable because: (1) user extensions are preserved via merge,
@@ -258,8 +262,6 @@ def add_skill(
258
262
 
259
263
  def has_user_extensions(skill_dir: Path) -> bool:
260
264
  """Check if SKILL.md has user content in extensions region."""
261
- import re
262
-
263
265
  skill_md = skill_dir / "SKILL.md"
264
266
  if not skill_md.exists():
265
267
  return False
@@ -283,7 +285,7 @@ def has_user_extensions(skill_dir: Path) -> bool:
283
285
 
284
286
  # Check if any non-whitespace content remains
285
287
  return bool(cleaned.strip())
286
- except Exception:
288
+ except (ValueError, KeyError):
287
289
  # Parse error - assume extensions exist (safe default)
288
290
  return True
289
291
 
@@ -334,7 +336,7 @@ def remove_skill(
334
336
  try:
335
337
  shutil.rmtree(dest_dir)
336
338
  return Success(f"Skill '{skill_name}' removed successfully")
337
- except Exception as e:
339
+ except (OSError, shutil.Error) as e:
338
340
  return Failure(f"Failed to remove skill: {e}")
339
341
 
340
342