familiar-cli 0.5.0__tar.gz → 0.5.1__tar.gz

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 (53) hide show
  1. {familiar_cli-0.5.0/src/familiar_cli.egg-info → familiar_cli-0.5.1}/PKG-INFO +1 -1
  2. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/pyproject.toml +1 -1
  3. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/cli.py +37 -23
  4. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/lint.py +1 -1
  5. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/render.py +24 -5
  6. {familiar_cli-0.5.0 → familiar_cli-0.5.1/src/familiar_cli.egg-info}/PKG-INFO +1 -1
  7. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_cli.py +91 -0
  8. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_lint.py +44 -0
  9. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_render.py +75 -0
  10. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/LICENSE +0 -0
  11. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/readme.md +0 -0
  12. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/setup.cfg +0 -0
  13. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/__init__.py +0 -0
  14. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/_plugins.py +0 -0
  15. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/agents.py +0 -0
  16. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/core.md +0 -0
  17. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/data.md +0 -0
  18. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/docs.md +0 -0
  19. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/frontend.md +0 -0
  20. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/infra.md +0 -0
  21. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/python.md +0 -0
  22. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/rust.md +0 -0
  23. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/sec.md +0 -0
  24. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/__noop__.md +0 -0
  25. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-ci.md +0 -0
  26. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-tests.md +0 -0
  27. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/audit.md +0 -0
  28. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  29. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  30. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/code-review.md +0 -0
  31. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/explain.md +0 -0
  32. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/implement-feature.md +0 -0
  33. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/infra-change.md +0 -0
  34. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/performance.md +0 -0
  35. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/refactor.md +0 -0
  36. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/release.md +0 -0
  37. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/security-review.md +0 -0
  38. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  39. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  40. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  41. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  42. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  43. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  44. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  45. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  46. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/py.typed +0 -0
  47. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  48. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  49. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  50. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/requires.txt +0 -0
  51. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
  52. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_agents.py +0 -0
  53. {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: compose and invoke ai agent prompts from reusable templates
5
5
  Author-email: cyberwitchery lab <contact@cyberwitchery.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "familiar-cli"
3
- version = "0.5.0"
3
+ version = "0.5.1"
4
4
  description = "compose and invoke ai agent prompts from reusable templates"
5
5
  readme = "readme.md"
6
6
  license = "MIT"
@@ -14,7 +14,7 @@ import warnings
14
14
  from importlib.metadata import version
15
15
  from pathlib import Path
16
16
 
17
- from .agents import get_agents, get_agent
17
+ from .agents import Agent, get_agents, get_agent
18
18
  from .lint import lint_all
19
19
  from .render import (
20
20
  NotFoundError,
@@ -47,6 +47,27 @@ def _agent_hint() -> str:
47
47
  return f"valid agents: {', '.join(get_agents().keys())}"
48
48
 
49
49
 
50
+ def _resolve_agent(name: str) -> "Agent":
51
+ """Look up an agent by name, raising CliError on unknown names."""
52
+ try:
53
+ return get_agent(name)
54
+ except KeyError as e:
55
+ raise CliError(str(e), hint=_agent_hint())
56
+
57
+
58
+ def _write_file(path: Path, content: str, label: str = "") -> None:
59
+ """Write content to path, creating parent dirs. Wraps OSError as CliError."""
60
+ prefix = f"{label} " if label else ""
61
+ try:
62
+ path.parent.mkdir(parents=True, exist_ok=True)
63
+ path.write_text(content, encoding="utf-8")
64
+ except OSError as e:
65
+ raise CliError(
66
+ f"cannot write {prefix}{path}: {e}",
67
+ hint="check file permissions and disk space",
68
+ )
69
+
70
+
50
71
  def find_repo_root(start: Path) -> Path:
51
72
  """Find the repository root by looking for .git directory.
52
73
 
@@ -63,7 +84,7 @@ def find_repo_root(start: Path) -> Path:
63
84
  def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
64
85
  """Best-effort removal of a git worktree."""
65
86
  try:
66
- subprocess.run(
87
+ result = subprocess.run(
67
88
  [
68
89
  "git",
69
90
  "-C",
@@ -75,6 +96,12 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
75
96
  ],
76
97
  capture_output=True,
77
98
  )
99
+ if result.returncode != 0:
100
+ stderr = result.stderr.decode(errors="replace").strip()
101
+ warnings.warn(
102
+ f"failed to remove worktree {worktree_path}: {stderr}",
103
+ stacklevel=2,
104
+ )
78
105
  except FileNotFoundError:
79
106
  pass
80
107
 
@@ -120,11 +147,9 @@ def create_worktree(repo_root: Path) -> Path:
120
147
 
121
148
 
122
149
  def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
123
- try:
124
- agent = get_agent(agent_name)
125
- except KeyError as e:
126
- raise CliError(str(e), hint=_agent_hint())
127
- (repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
150
+ agent = _resolve_agent(agent_name)
151
+ dest = repo_root / agent.output_file
152
+ _write_file(dest, system.strip() + "\n")
128
153
 
129
154
 
130
155
  def write_skill(
@@ -137,16 +162,12 @@ def write_skill(
137
162
  hint="use lowercase letters, numbers, underscore, or hyphen",
138
163
  )
139
164
 
140
- try:
141
- agent = get_agent(agent_name)
142
- except KeyError as e:
143
- raise CliError(str(e), hint=_agent_hint())
165
+ agent = _resolve_agent(agent_name)
144
166
 
145
167
  if not agent.supports_skills():
146
168
  raise CliError(f"agent does not support skills: {agent_name}")
147
169
 
148
170
  skill_path = agent.skill_path(repo_root, skill_name)
149
- skill_path.parent.mkdir(parents=True, exist_ok=True)
150
171
  content = (
151
172
  f"# {skill_name}\n\n"
152
173
  "use this skill when this recurring task appears in this repository.\n\n"
@@ -156,7 +177,7 @@ def write_skill(
156
177
  "## instructions\n\n"
157
178
  f"{prompt.strip()}\n"
158
179
  )
159
- skill_path.write_text(content, encoding="utf-8")
180
+ _write_file(skill_path, content, label="skill")
160
181
  return skill_path
161
182
 
162
183
 
@@ -174,16 +195,12 @@ def write_subagent(
174
195
  hint="use lowercase letters, numbers, underscore, or hyphen",
175
196
  )
176
197
 
177
- try:
178
- agent = get_agent(agent_name)
179
- except KeyError as e:
180
- raise CliError(str(e), hint=_agent_hint())
198
+ agent = _resolve_agent(agent_name)
181
199
 
182
200
  if not agent.supports_subagents():
183
201
  raise CliError(f"agent does not support subagents: {agent_name}")
184
202
 
185
203
  subagent_path = agent.subagent_path(repo_root, subagent_name)
186
- subagent_path.parent.mkdir(parents=True, exist_ok=True)
187
204
  profile = ", ".join(conjurings)
188
205
  content = (
189
206
  f"# {subagent_name}\n\n"
@@ -194,7 +211,7 @@ def write_subagent(
194
211
  "## instructions\n\n"
195
212
  f"{system.strip()}\n"
196
213
  )
197
- subagent_path.write_text(content, encoding="utf-8")
214
+ _write_file(subagent_path, content, label="subagent")
198
215
  return subagent_path
199
216
 
200
217
 
@@ -214,10 +231,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
214
231
  def run_agent(
215
232
  repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
216
233
  ) -> int:
217
- try:
218
- agent = get_agent(agent_name)
219
- except KeyError as e:
220
- raise CliError(str(e), hint=_agent_hint())
234
+ agent = _resolve_agent(agent_name)
221
235
  try:
222
236
  return agent.run(repo_root, prompt, headless, auto=auto)
223
237
  except FileNotFoundError:
@@ -265,7 +265,7 @@ def lint_collection(
265
265
  message=f"plugin linter failed: {e}",
266
266
  )
267
267
  )
268
- except Exception as e:
268
+ except NotFoundError as e:
269
269
  messages.append(
270
270
  LintMessage(
271
271
  level="error",
@@ -21,13 +21,23 @@ class NotFoundError(Exception):
21
21
  """Raised when a conjuring, invocation, or snippet is not found."""
22
22
 
23
23
 
24
+ def _safe_read_text(path: Path, label: str) -> str:
25
+ """Read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
26
+ try:
27
+ return path.read_text(encoding="utf-8")
28
+ except PermissionError:
29
+ raise NotFoundError(f"cannot read {label}: permission denied on {path}")
30
+ except UnicodeDecodeError:
31
+ raise NotFoundError(f"cannot read {label}: {path} is not valid UTF-8")
32
+
33
+
24
34
  def load_text(repo_root: Path, kind: str, name: str) -> str:
25
35
  """Load a conjuring or invocation; local overrides in .familiar override package data."""
26
36
  if not _VALID_NAME.match(name):
27
37
  raise NotFoundError(f"invalid {kind.rstrip('s')} name: {name}")
28
38
  override = repo_root / ".familiar" / kind / f"{name}.md"
29
39
  if override.exists():
30
- return override.read_text(encoding="utf-8")
40
+ return _safe_read_text(override, f"{kind.rstrip('s')} '{name}'")
31
41
  pkg = f"familiar.data.{kind}"
32
42
  try:
33
43
  return (resources.files(pkg) / f"{name}.md").read_text(encoding="utf-8")
@@ -47,7 +57,7 @@ def load_snippet(repo_root: Path, path: str) -> str:
47
57
 
48
58
  override = repo_root / ".familiar" / "snippets" / path
49
59
  if override.exists():
50
- return override.read_text(encoding="utf-8")
60
+ return _safe_read_text(override, f"snippet '{path}'")
51
61
 
52
62
  try:
53
63
  ref: Traversable = resources.files("familiar.data.snippets")
@@ -110,7 +120,10 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
110
120
  if item.is_dir():
111
121
  items.extend(_walk_traversable(item, rel))
112
122
  elif not item.name.startswith("_"):
113
- content = item.read_text(encoding="utf-8")
123
+ try:
124
+ content = item.read_text(encoding="utf-8")
125
+ except (UnicodeDecodeError, PermissionError):
126
+ continue
114
127
  first_line = content.split("\n", 1)[0].strip()
115
128
  items.append((rel, first_line))
116
129
  except (FileNotFoundError, TypeError):
@@ -155,7 +168,10 @@ def _list_resources(
155
168
  if item.name.startswith("_"):
156
169
  continue
157
170
  key = item.name[: -len(suffix)] if suffix else item.name
158
- content = item.read_text(encoding="utf-8")
171
+ try:
172
+ content = item.read_text(encoding="utf-8")
173
+ except (UnicodeDecodeError, PermissionError):
174
+ continue
159
175
  first_line = content.split("\n", 1)[0].strip()
160
176
  items[key] = (first_line, False)
161
177
  except (FileNotFoundError, TypeError, ModuleNotFoundError):
@@ -171,7 +187,10 @@ def _list_resources(
171
187
  if not f.is_file() or f.name.startswith("_"):
172
188
  continue
173
189
  key = str(f.relative_to(local_dir)) if recursive else f.stem
174
- content = f.read_text(encoding="utf-8")
190
+ try:
191
+ content = f.read_text(encoding="utf-8")
192
+ except (UnicodeDecodeError, PermissionError):
193
+ continue
175
194
  first_line = content.split("\n", 1)[0].strip()
176
195
  items[key] = (first_line, True)
177
196
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.0
3
+ Version: 0.5.1
4
4
  Summary: compose and invoke ai agent prompts from reusable templates
5
5
  Author-email: cyberwitchery lab <contact@cyberwitchery.com>
6
6
  License-Expression: MIT
@@ -8,9 +8,14 @@ from unittest.mock import patch
8
8
  import argparse
9
9
 
10
10
  from familiar.cli import (
11
+ _remove_worktree,
12
+ _resolve_agent,
13
+ _write_file,
11
14
  find_repo_root,
12
15
  create_worktree,
13
16
  write_instruction,
17
+ write_skill,
18
+ write_subagent,
14
19
  parse_kv,
15
20
  run_agent,
16
21
  main,
@@ -84,6 +89,50 @@ class TestFindRepoRoot:
84
89
  assert result == subdir.resolve()
85
90
 
86
91
 
92
+ class TestResolveAgent:
93
+ """tests for _resolve_agent helper."""
94
+
95
+ def test_returns_known_agent(self):
96
+ agent = _resolve_agent("claude")
97
+ assert agent.name == "claude"
98
+
99
+ def test_unknown_agent_raises(self):
100
+ with pytest.raises(CliError, match="unknown agent"):
101
+ _resolve_agent("nonexistent")
102
+
103
+ def test_unknown_agent_includes_hint(self):
104
+ with pytest.raises(CliError) as exc_info:
105
+ _resolve_agent("nonexistent")
106
+ assert exc_info.value.hint is not None
107
+ assert "valid agents" in exc_info.value.hint
108
+
109
+
110
+ class TestWriteFile:
111
+ """tests for _write_file helper."""
112
+
113
+ def test_writes_content(self, tmp_path):
114
+ dest = tmp_path / "out.txt"
115
+ _write_file(dest, "hello\n")
116
+ assert dest.read_text() == "hello\n"
117
+
118
+ def test_creates_parent_dirs(self, tmp_path):
119
+ dest = tmp_path / "a" / "b" / "out.txt"
120
+ _write_file(dest, "nested\n")
121
+ assert dest.read_text() == "nested\n"
122
+
123
+ def test_oserror_without_label(self, tmp_path):
124
+ dest = tmp_path / "out.txt"
125
+ with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
126
+ with pytest.raises(CliError, match="cannot write .*/out.txt"):
127
+ _write_file(dest, "content")
128
+
129
+ def test_oserror_with_label(self, tmp_path):
130
+ dest = tmp_path / "out.txt"
131
+ with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
132
+ with pytest.raises(CliError, match="cannot write skill .*/out.txt"):
133
+ _write_file(dest, "content", label="skill")
134
+
135
+
87
136
  class TestWriteInstruction:
88
137
  """tests for writing instruction files."""
89
138
 
@@ -100,6 +149,48 @@ class TestWriteInstruction:
100
149
  assert (tmp_path / "CLAUDE.md").read_text() == "content\n"
101
150
 
102
151
 
152
+ class TestRemoveWorktree:
153
+ """tests for git worktree removal."""
154
+
155
+ def test_warns_on_nonzero_return_code(self, tmp_path):
156
+ with patch("subprocess.run") as mock_run:
157
+ mock_run.return_value = argparse.Namespace(
158
+ returncode=1, stderr=b"worktree not found"
159
+ )
160
+ with pytest.warns(UserWarning, match="failed to remove worktree"):
161
+ _remove_worktree(tmp_path, tmp_path / "wt")
162
+
163
+ def test_no_warning_on_success(self, tmp_path, recwarn):
164
+ with patch("subprocess.run") as mock_run:
165
+ mock_run.return_value = argparse.Namespace(returncode=0, stderr=b"")
166
+ _remove_worktree(tmp_path, tmp_path / "wt")
167
+ assert len(recwarn) == 0
168
+
169
+ def test_silent_on_missing_git(self, tmp_path, recwarn):
170
+ with patch("subprocess.run", side_effect=FileNotFoundError):
171
+ _remove_worktree(tmp_path, tmp_path / "wt")
172
+ assert len(recwarn) == 0
173
+
174
+
175
+ class TestWriteFailures:
176
+ """tests for write error handling."""
177
+
178
+ def test_write_instruction_permission_denied(self, tmp_path):
179
+ with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
180
+ with pytest.raises(CliError, match="cannot write"):
181
+ write_instruction(tmp_path, "claude", "content")
182
+
183
+ def test_write_skill_permission_denied(self, tmp_path):
184
+ with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
185
+ with pytest.raises(CliError, match="cannot write skill"):
186
+ write_skill(tmp_path, "claude", "explain", "my-skill", "prompt")
187
+
188
+ def test_write_subagent_permission_denied(self, tmp_path):
189
+ with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
190
+ with pytest.raises(CliError, match="cannot write subagent"):
191
+ write_subagent(tmp_path, "claude", ["python"], "my-subagent", "system")
192
+
193
+
103
194
  class TestParseKv:
104
195
  """tests for key-value parsing."""
105
196
 
@@ -9,11 +9,13 @@ import pytest
9
9
  from familiar.lint import (
10
10
  LintMessage,
11
11
  lint_all,
12
+ lint_collection,
12
13
  lint_invocation,
13
14
  lint_snippet_references,
14
15
  lint_template,
15
16
  load_linters,
16
17
  )
18
+ from familiar.render import NotFoundError
17
19
 
18
20
 
19
21
  class TestLintTemplate:
@@ -301,6 +303,48 @@ class TestLinterPlugins:
301
303
  assert len(error_messages) >= 1
302
304
 
303
305
 
306
+ class TestLintCollectionErrorHandling:
307
+ """tests for error handling in lint_collection."""
308
+
309
+ def test_load_error_produces_lint_error(self, tmp_path):
310
+ """A NotFoundError from load_text should produce a lint error, not crash."""
311
+ templates = tmp_path / ".familiar" / "conjurings"
312
+ templates.mkdir(parents=True)
313
+ (templates / "broken.md").write_text("# heading")
314
+
315
+ from familiar import lint as _lint_mod
316
+
317
+ original = _lint_mod.load_text
318
+
319
+ def patched(repo_root, kind, name):
320
+ if name == "broken":
321
+ raise NotFoundError("simulated read failure")
322
+ return original(repo_root, kind, name)
323
+
324
+ with patch.object(_lint_mod, "load_text", side_effect=patched):
325
+ messages = lint_collection(tmp_path, "conjurings", lint_template, [])
326
+
327
+ load_errors = [
328
+ m
329
+ for m in messages
330
+ if "broken.md" in m.file and "failed to load" in m.message
331
+ ]
332
+ assert len(load_errors) == 1
333
+ assert load_errors[0].level == "error"
334
+
335
+ def test_unexpected_linter_error_propagates(self, tmp_path):
336
+ """Errors not wrapped in NotFoundError should propagate, not be swallowed."""
337
+ templates = tmp_path / ".familiar" / "conjurings"
338
+ templates.mkdir(parents=True)
339
+ (templates / "test.md").write_text("# test")
340
+
341
+ def exploding_linter(content: str, name: str) -> list[LintMessage]:
342
+ raise RuntimeError("unexpected")
343
+
344
+ with pytest.raises(RuntimeError, match="unexpected"):
345
+ lint_collection(tmp_path, "conjurings", exploding_linter, [])
346
+
347
+
304
348
  class TestLintSnippetReferences:
305
349
  """tests for snippet reference validation."""
306
350
 
@@ -100,6 +100,25 @@ class TestLoadText:
100
100
  with pytest.raises(NotFoundError, match="unknown invocation"):
101
101
  load_text(tmp_path, "invocations", "nonexistent")
102
102
 
103
+ def test_permission_denied_raises(self, tmp_path):
104
+ override_dir = tmp_path / ".familiar" / "conjurings"
105
+ override_dir.mkdir(parents=True)
106
+ f = override_dir / "locked.md"
107
+ f.write_text("content")
108
+ f.chmod(0o000)
109
+ try:
110
+ with pytest.raises(NotFoundError, match="permission denied"):
111
+ load_text(tmp_path, "conjurings", "locked")
112
+ finally:
113
+ f.chmod(0o644)
114
+
115
+ def test_invalid_utf8_raises(self, tmp_path):
116
+ override_dir = tmp_path / ".familiar" / "conjurings"
117
+ override_dir.mkdir(parents=True)
118
+ (override_dir / "binary.md").write_bytes(b"\xff\xfe invalid utf-8")
119
+ with pytest.raises(NotFoundError, match="not valid UTF-8"):
120
+ load_text(tmp_path, "conjurings", "binary")
121
+
103
122
 
104
123
  class TestComposeSystem:
105
124
  """tests for composing system prompts."""
@@ -262,6 +281,32 @@ class TestListItems:
262
281
  items = list_items(tmp_path, "conjurings")
263
282
  assert len(items) > 0
264
283
 
284
+ def test_list_skips_invalid_utf8_local(self, tmp_path):
285
+ templates = tmp_path / ".familiar" / "conjurings"
286
+ templates.mkdir(parents=True)
287
+ (templates / "good.md").write_text("# good file")
288
+ (templates / "bad.md").write_bytes(b"\xff\xfe not utf-8")
289
+
290
+ items = list_items(tmp_path, "conjurings")
291
+ names = [name for name, _, _ in items]
292
+ assert "good" in names
293
+ assert "bad" not in names
294
+
295
+ def test_list_skips_permission_denied_local(self, tmp_path):
296
+ templates = tmp_path / ".familiar" / "conjurings"
297
+ templates.mkdir(parents=True)
298
+ (templates / "good.md").write_text("# good file")
299
+ locked = templates / "locked.md"
300
+ locked.write_text("content")
301
+ locked.chmod(0o000)
302
+ try:
303
+ items = list_items(tmp_path, "conjurings")
304
+ names = [name for name, _, _ in items]
305
+ assert "good" in names
306
+ assert "locked" not in names
307
+ finally:
308
+ locked.chmod(0o644)
309
+
265
310
 
266
311
  class TestLoadSnippet:
267
312
  """tests for loading snippets."""
@@ -289,6 +334,25 @@ class TestLoadSnippet:
289
334
  with pytest.raises(NotFoundError, match="unknown snippet"):
290
335
  load_snippet(tmp_path, "nonexistent/file.txt")
291
336
 
337
+ def test_permission_denied_raises(self, tmp_path):
338
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
339
+ snippet_dir.mkdir(parents=True)
340
+ f = snippet_dir / "locked.txt"
341
+ f.write_text("content")
342
+ f.chmod(0o000)
343
+ try:
344
+ with pytest.raises(NotFoundError, match="permission denied"):
345
+ load_snippet(tmp_path, "test/locked.txt")
346
+ finally:
347
+ f.chmod(0o644)
348
+
349
+ def test_invalid_utf8_raises(self, tmp_path):
350
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
351
+ snippet_dir.mkdir(parents=True)
352
+ (snippet_dir / "binary.txt").write_bytes(b"\xff\xfe invalid utf-8")
353
+ with pytest.raises(NotFoundError, match="not valid UTF-8"):
354
+ load_snippet(tmp_path, "test/binary.txt")
355
+
292
356
 
293
357
  class TestResolveIncludes:
294
358
  """tests for snippet include resolution."""
@@ -414,3 +478,14 @@ class TestListSnippets:
414
478
  paths = [p for p, _, _ in items]
415
479
  assert "test/visible.txt" in paths
416
480
  assert "test/_hidden.txt" not in paths
481
+
482
+ def test_list_snippets_skips_invalid_utf8(self, tmp_path):
483
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
484
+ snippet_dir.mkdir(parents=True)
485
+ (snippet_dir / "good.txt").write_text("good content")
486
+ (snippet_dir / "bad.txt").write_bytes(b"\xff\xfe not utf-8")
487
+
488
+ items = list_snippets(tmp_path)
489
+ local_paths = [p for p, _, is_local in items if is_local]
490
+ assert "test/good.txt" in local_paths
491
+ assert "test/bad.txt" not in local_paths
File without changes
File without changes
File without changes