familiar-cli 0.4.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.4.0/src/familiar_cli.egg-info → familiar_cli-0.5.1}/PKG-INFO +1 -1
  2. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/pyproject.toml +1 -1
  3. familiar_cli-0.5.1/src/familiar/_plugins.py +44 -0
  4. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/agents.py +10 -13
  5. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/cli.py +76 -29
  6. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/lint.py +9 -24
  7. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/render.py +80 -47
  8. {familiar_cli-0.4.0 → familiar_cli-0.5.1/src/familiar_cli.egg-info}/PKG-INFO +1 -1
  9. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
  10. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_agents.py +3 -3
  11. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_cli.py +126 -0
  12. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_lint.py +48 -4
  13. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_render.py +164 -0
  14. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/LICENSE +0 -0
  15. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/readme.md +0 -0
  16. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/setup.cfg +0 -0
  17. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/__init__.py +0 -0
  18. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/core.md +0 -0
  19. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/data.md +0 -0
  20. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/docs.md +0 -0
  21. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/frontend.md +0 -0
  22. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/infra.md +0 -0
  23. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/python.md +0 -0
  24. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/rust.md +0 -0
  25. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/sec.md +0 -0
  26. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/__noop__.md +0 -0
  27. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-ci.md +0 -0
  28. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-tests.md +0 -0
  29. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/audit.md +0 -0
  30. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  31. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  32. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/code-review.md +0 -0
  33. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/explain.md +0 -0
  34. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/implement-feature.md +0 -0
  35. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/infra-change.md +0 -0
  36. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/performance.md +0 -0
  37. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/refactor.md +0 -0
  38. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/release.md +0 -0
  39. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/security-review.md +0 -0
  40. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  41. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  42. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  43. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  44. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  45. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  46. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  47. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  48. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/py.typed +0 -0
  49. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  50. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  51. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/requires.txt +0 -0
  52. {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
  53. {familiar_cli-0.4.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.4.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.4.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"
@@ -0,0 +1,44 @@
1
+ """Shared plugin loading utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+ from importlib.metadata import entry_points
7
+ from typing import Any, Callable
8
+
9
+
10
+ def load_plugins(
11
+ group: str,
12
+ validate: Callable[[Any], bool],
13
+ *,
14
+ label: str,
15
+ invalid_msg: str = "validation failed",
16
+ ) -> list[Any]:
17
+ """Load plugins from entry points with validation.
18
+
19
+ Args:
20
+ group: Entry point group name.
21
+ validate: Predicate that returns True for valid loaded objects.
22
+ label: Human-readable label for warning messages (e.g. "linter plugin").
23
+ invalid_msg: Message when validation fails.
24
+
25
+ Returns:
26
+ List of loaded and validated plugin objects.
27
+ """
28
+ results: list[Any] = []
29
+ for ep in entry_points(group=group):
30
+ try:
31
+ obj = ep.load()
32
+ if not validate(obj):
33
+ warnings.warn(
34
+ f"{label} '{ep.name}': {invalid_msg}",
35
+ stacklevel=2,
36
+ )
37
+ continue
38
+ results.append(obj)
39
+ except Exception as e:
40
+ warnings.warn(
41
+ f"failed to load {label} '{ep.name}': {e}",
42
+ stacklevel=2,
43
+ )
44
+ return results
@@ -5,9 +5,10 @@ from __future__ import annotations
5
5
  import subprocess
6
6
  import warnings
7
7
  from abc import ABC, abstractmethod
8
- from importlib.metadata import entry_points
9
8
  from pathlib import Path
10
9
 
10
+ from ._plugins import load_plugins
11
+
11
12
 
12
13
  class Agent(ABC):
13
14
  """Base class for AI coding agents."""
@@ -96,26 +97,22 @@ def load_agents() -> dict[str, Agent]:
96
97
  Dictionary mapping agent names to Agent instances.
97
98
  Plugins that fail to load are skipped with a warning.
98
99
  """
100
+ plugins = load_plugins(
101
+ "familiar.agents",
102
+ lambda cls: isinstance(cls, type) and issubclass(cls, Agent),
103
+ label="agent plugin",
104
+ invalid_msg="not a valid Agent subclass",
105
+ )
99
106
  agents: dict[str, Agent] = {}
100
- eps = entry_points(group="familiar.agents")
101
-
102
- for ep in eps:
107
+ for cls in plugins:
103
108
  try:
104
- cls = ep.load()
105
- if not (isinstance(cls, type) and issubclass(cls, Agent)):
106
- warnings.warn(
107
- f"agent plugin '{ep.name}': not a valid Agent subclass",
108
- stacklevel=2,
109
- )
110
- continue
111
109
  instance = cls()
112
110
  agents[instance.name] = instance
113
111
  except Exception as e:
114
112
  warnings.warn(
115
- f"failed to load agent plugin '{ep.name}': {e}",
113
+ f"failed to load agent plugin '{cls.__name__}': {e}",
116
114
  stacklevel=2,
117
115
  )
118
-
119
116
  return agents
120
117
 
121
118
 
@@ -10,9 +10,11 @@ import subprocess
10
10
  import sys
11
11
  import tempfile
12
12
  import traceback
13
+ import warnings
14
+ from importlib.metadata import version
13
15
  from pathlib import Path
14
16
 
15
- from .agents import get_agents, get_agent
17
+ from .agents import Agent, get_agents, get_agent
16
18
  from .lint import lint_all
17
19
  from .render import (
18
20
  NotFoundError,
@@ -26,8 +28,7 @@ from .render import (
26
28
  EXIT_SUCCESS = 0
27
29
  EXIT_ERROR = 1 # general error (agent failed, etc.)
28
30
  EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
29
- _VALID_SKILL_NAME = re.compile(r"^[a-z0-9_-]+$")
30
- _VALID_SUBAGENT_NAME = re.compile(r"^[a-z0-9_-]+$")
31
+ _VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
31
32
 
32
33
 
33
34
  class CliError(Exception):
@@ -41,6 +42,32 @@ class CliError(Exception):
41
42
  self.exit_code = exit_code
42
43
 
43
44
 
45
+ def _agent_hint() -> str:
46
+ """Return a hint string listing valid agent names."""
47
+ return f"valid agents: {', '.join(get_agents().keys())}"
48
+
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
+
44
71
  def find_repo_root(start: Path) -> Path:
45
72
  """Find the repository root by looking for .git directory.
46
73
 
@@ -54,6 +81,31 @@ def find_repo_root(start: Path) -> Path:
54
81
  return cur
55
82
 
56
83
 
84
+ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
85
+ """Best-effort removal of a git worktree."""
86
+ try:
87
+ result = subprocess.run(
88
+ [
89
+ "git",
90
+ "-C",
91
+ str(repo_root),
92
+ "worktree",
93
+ "remove",
94
+ "--force",
95
+ str(worktree_path),
96
+ ],
97
+ capture_output=True,
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
+ )
105
+ except FileNotFoundError:
106
+ pass
107
+
108
+
57
109
  def create_worktree(repo_root: Path) -> Path:
58
110
  """Create a temporary git worktree for the repository."""
59
111
  # Ensure it's a git repo
@@ -95,33 +147,27 @@ def create_worktree(repo_root: Path) -> Path:
95
147
 
96
148
 
97
149
  def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
98
- try:
99
- agent = get_agent(agent_name)
100
- except KeyError as e:
101
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
102
- (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")
103
153
 
104
154
 
105
155
  def write_skill(
106
156
  repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
107
157
  ) -> Path:
108
158
  """Write a reusable skill file for supported agents."""
109
- if not _VALID_SKILL_NAME.match(skill_name):
159
+ if not _VALID_NAME.match(skill_name):
110
160
  raise CliError(
111
161
  f"invalid skill name: {skill_name}",
112
162
  hint="use lowercase letters, numbers, underscore, or hyphen",
113
163
  )
114
164
 
115
- try:
116
- agent = get_agent(agent_name)
117
- except KeyError as e:
118
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
165
+ agent = _resolve_agent(agent_name)
119
166
 
120
167
  if not agent.supports_skills():
121
168
  raise CliError(f"agent does not support skills: {agent_name}")
122
169
 
123
170
  skill_path = agent.skill_path(repo_root, skill_name)
124
- skill_path.parent.mkdir(parents=True, exist_ok=True)
125
171
  content = (
126
172
  f"# {skill_name}\n\n"
127
173
  "use this skill when this recurring task appears in this repository.\n\n"
@@ -131,7 +177,7 @@ def write_skill(
131
177
  "## instructions\n\n"
132
178
  f"{prompt.strip()}\n"
133
179
  )
134
- skill_path.write_text(content, encoding="utf-8")
180
+ _write_file(skill_path, content, label="skill")
135
181
  return skill_path
136
182
 
137
183
 
@@ -143,22 +189,18 @@ def write_subagent(
143
189
  system: str,
144
190
  ) -> Path:
145
191
  """Write a reusable subagent file for supported agents."""
146
- if not _VALID_SUBAGENT_NAME.match(subagent_name):
192
+ if not _VALID_NAME.match(subagent_name):
147
193
  raise CliError(
148
194
  f"invalid subagent name: {subagent_name}",
149
195
  hint="use lowercase letters, numbers, underscore, or hyphen",
150
196
  )
151
197
 
152
- try:
153
- agent = get_agent(agent_name)
154
- except KeyError as e:
155
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
198
+ agent = _resolve_agent(agent_name)
156
199
 
157
200
  if not agent.supports_subagents():
158
201
  raise CliError(f"agent does not support subagents: {agent_name}")
159
202
 
160
203
  subagent_path = agent.subagent_path(repo_root, subagent_name)
161
- subagent_path.parent.mkdir(parents=True, exist_ok=True)
162
204
  profile = ", ".join(conjurings)
163
205
  content = (
164
206
  f"# {subagent_name}\n\n"
@@ -169,7 +211,7 @@ def write_subagent(
169
211
  "## instructions\n\n"
170
212
  f"{system.strip()}\n"
171
213
  )
172
- subagent_path.write_text(content, encoding="utf-8")
214
+ _write_file(subagent_path, content, label="subagent")
173
215
  return subagent_path
174
216
 
175
217
 
@@ -189,10 +231,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
189
231
  def run_agent(
190
232
  repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
191
233
  ) -> int:
192
- try:
193
- agent = get_agent(agent_name)
194
- except KeyError as e:
195
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
234
+ agent = _resolve_agent(agent_name)
196
235
  try:
197
236
  return agent.run(repo_root, prompt, headless, auto=auto)
198
237
  except FileNotFoundError:
@@ -258,8 +297,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
258
297
  instr_file = repo_root / agent.output_file
259
298
  if instr_file.exists():
260
299
  shutil.copy2(instr_file, target_dir / agent.output_file)
261
- except (OSError, KeyError):
262
- pass
300
+ except (OSError, KeyError) as exc:
301
+ warnings.warn(
302
+ f"failed to copy instruction file into worktree: {exc}",
303
+ stacklevel=2,
304
+ )
263
305
 
264
306
  try:
265
307
  rc = run_agent(
@@ -271,7 +313,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
271
313
  return rc
272
314
  except Exception:
273
315
  if args.worktree:
274
- print(f"\nworktree remains at: {target_dir}")
316
+ _remove_worktree(repo_root, target_dir)
275
317
  raise
276
318
 
277
319
 
@@ -357,6 +399,11 @@ def main() -> None:
357
399
  " familiar lint # validate prompts\n",
358
400
  formatter_class=argparse.RawDescriptionHelpFormatter,
359
401
  )
402
+ parser.add_argument(
403
+ "--version",
404
+ action="version",
405
+ version=f"%(prog)s {version('familiar-cli')}",
406
+ )
360
407
  parser.add_argument(
361
408
  "--debug", action="store_true", help="show full traceback on error"
362
409
  )
@@ -3,12 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
- import warnings
7
6
  from dataclasses import dataclass
8
- from importlib.metadata import entry_points
9
7
  from pathlib import Path
10
8
  from typing import Callable, Literal
11
9
 
10
+ from ._plugins import load_plugins
11
+
12
12
  from .render import _SNIPPET_INCLUDE, NotFoundError, list_items, load_snippet, load_text
13
13
 
14
14
 
@@ -202,27 +202,12 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
202
202
  Returns:
203
203
  List of linter functions from plugins.
204
204
  """
205
- linters: list[LinterFunc] = []
206
- group = f"familiar.linters.{kind}"
207
- eps = entry_points(group=group)
208
-
209
- for ep in eps:
210
- try:
211
- func = ep.load()
212
- if not callable(func):
213
- warnings.warn(
214
- f"linter plugin '{ep.name}': not callable",
215
- stacklevel=2,
216
- )
217
- continue
218
- linters.append(func)
219
- except Exception as e:
220
- warnings.warn(
221
- f"failed to load linter plugin '{ep.name}': {e}",
222
- stacklevel=2,
223
- )
224
-
225
- return linters
205
+ return load_plugins(
206
+ f"familiar.linters.{kind}",
207
+ callable,
208
+ label="linter plugin",
209
+ invalid_msg="not callable",
210
+ )
226
211
 
227
212
 
228
213
  def lint_snippet_references(
@@ -280,7 +265,7 @@ def lint_collection(
280
265
  message=f"plugin linter failed: {e}",
281
266
  )
282
267
  )
283
- except Exception as e:
268
+ except NotFoundError as e:
284
269
  messages.append(
285
270
  LintMessage(
286
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):
@@ -118,71 +131,91 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
118
131
  return items
119
132
 
120
133
 
121
- def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
122
- """List available snippets.
134
+ def _list_resources(
135
+ repo_root: Path,
136
+ pkg: str,
137
+ local_subdir: str,
138
+ *,
139
+ recursive: bool = False,
140
+ suffix: str = "",
141
+ ) -> list[tuple[str, str, bool]]:
142
+ """List resources from package data and local overrides.
123
143
 
124
- Returns list of (path, first_line, is_local) tuples, sorted by path.
144
+ Items from local ``.familiar/{local_subdir}/`` override package builtins.
145
+
146
+ Args:
147
+ repo_root: Repository root path.
148
+ pkg: Package name to scan for builtins.
149
+ local_subdir: Subdirectory name under ``.familiar/`` for local overrides.
150
+ recursive: If True, scan recursively (for nested resources like snippets).
151
+ suffix: File suffix filter (e.g. ``".md"``). Empty string matches all files.
152
+
153
+ Returns:
154
+ Sorted list of ``(key, first_line, is_local)`` tuples.
125
155
  """
126
156
  items: dict[str, tuple[str, bool]] = {}
127
157
 
128
- # built-ins
158
+ # built-in resources from package data
129
159
  try:
130
- pkg_root = resources.files("familiar.data.snippets")
131
- for rel, first_line in _walk_traversable(pkg_root):
132
- items[rel] = (first_line, False)
160
+ pkg_root = resources.files(pkg)
161
+ if recursive:
162
+ for rel, first_line in _walk_traversable(pkg_root):
163
+ items[rel] = (first_line, False)
164
+ else:
165
+ for item in pkg_root.iterdir():
166
+ if suffix and not item.name.endswith(suffix):
167
+ continue
168
+ if item.name.startswith("_"):
169
+ continue
170
+ key = item.name[: -len(suffix)] if suffix else item.name
171
+ try:
172
+ content = item.read_text(encoding="utf-8")
173
+ except (UnicodeDecodeError, PermissionError):
174
+ continue
175
+ first_line = content.split("\n", 1)[0].strip()
176
+ items[key] = (first_line, False)
133
177
  except (FileNotFoundError, TypeError, ModuleNotFoundError):
134
178
  pass
135
179
 
136
180
  # local overrides
137
- local_dir = repo_root / ".familiar" / "snippets"
181
+ local_dir = repo_root / ".familiar" / local_subdir
138
182
  if local_dir.is_dir():
139
- for f in sorted(local_dir.rglob("*")):
140
- if f.is_file() and not f.name.startswith("_"):
141
- rel = str(f.relative_to(local_dir))
183
+ files = (
184
+ sorted(local_dir.rglob("*")) if recursive else local_dir.glob(f"*{suffix}")
185
+ )
186
+ for f in files:
187
+ if not f.is_file() or f.name.startswith("_"):
188
+ continue
189
+ key = str(f.relative_to(local_dir)) if recursive else f.stem
190
+ try:
142
191
  content = f.read_text(encoding="utf-8")
143
- first_line = content.split("\n", 1)[0].strip()
144
- items[rel] = (first_line, True)
192
+ except (UnicodeDecodeError, PermissionError):
193
+ continue
194
+ first_line = content.split("\n", 1)[0].strip()
195
+ items[key] = (first_line, True)
145
196
 
146
197
  return [
147
- (path, first_line, is_local)
148
- for path, (first_line, is_local) in sorted(items.items())
198
+ (key, first_line, is_local)
199
+ for key, (first_line, is_local) in sorted(items.items())
149
200
  ]
150
201
 
151
202
 
152
- def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
153
- """List available conjurings or invocations.
203
+ def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
204
+ """List available snippets.
154
205
 
155
- Returns list of (name, first_line, is_local) tuples, sorted by name.
206
+ Returns list of (path, first_line, is_local) tuples, sorted by path.
156
207
  """
157
- items: dict[str, tuple[str, bool]] = {}
208
+ return _list_resources(
209
+ repo_root, "familiar.data.snippets", "snippets", recursive=True
210
+ )
158
211
 
159
- # built-ins
160
- pkg = f"familiar.data.{kind}"
161
- try:
162
- pkg_files = resources.files(pkg)
163
- for item in pkg_files.iterdir():
164
- if item.name.endswith(".md") and not item.name.startswith("_"):
165
- name = item.name[:-3]
166
- content = item.read_text(encoding="utf-8")
167
- first_line = content.split("\n", 1)[0].strip()
168
- items[name] = (first_line, False)
169
- except (FileNotFoundError, TypeError):
170
- pass
171
212
 
172
- # local overrides
173
- local_dir = repo_root / ".familiar" / kind
174
- if local_dir.is_dir():
175
- for f in local_dir.glob("*.md"):
176
- if not f.name.startswith("_"):
177
- name = f.stem
178
- content = f.read_text(encoding="utf-8")
179
- first_line = content.split("\n", 1)[0].strip()
180
- items[name] = (first_line, True)
213
+ def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
214
+ """List available conjurings or invocations.
181
215
 
182
- return [
183
- (name, first_line, is_local)
184
- for name, (first_line, is_local) in sorted(items.items())
185
- ]
216
+ Returns list of (name, first_line, is_local) tuples, sorted by name.
217
+ """
218
+ return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
186
219
 
187
220
 
188
221
  def compose_system(repo_root: Path, conjurings: list[str]) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.4.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
@@ -2,6 +2,7 @@ LICENSE
2
2
  pyproject.toml
3
3
  readme.md
4
4
  src/familiar/__init__.py
5
+ src/familiar/_plugins.py
5
6
  src/familiar/agents.py
6
7
  src/familiar/cli.py
7
8
  src/familiar/lint.py
@@ -68,7 +68,7 @@ class TestPluginLoadErrors:
68
68
  mock_ep.name = "invalid"
69
69
  mock_ep.load.return_value = str # not an Agent subclass
70
70
 
71
- with patch("familiar.agents.entry_points", return_value=[mock_ep]):
71
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
72
72
  with pytest.warns(UserWarning, match="not a valid Agent subclass"):
73
73
  agents = load_agents()
74
74
  assert "invalid" not in agents
@@ -79,7 +79,7 @@ class TestPluginLoadErrors:
79
79
  mock_ep.name = "broken"
80
80
  mock_ep.load.side_effect = ImportError("module not found")
81
81
 
82
- with patch("familiar.agents.entry_points", return_value=[mock_ep]):
82
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
83
83
  with pytest.warns(UserWarning, match="failed to load"):
84
84
  agents = load_agents()
85
85
  assert "broken" not in agents
@@ -101,7 +101,7 @@ class TestPluginLoadErrors:
101
101
  mock_ep.name = "bad"
102
102
  mock_ep.load.return_value = BadAgent
103
103
 
104
- with patch("familiar.agents.entry_points", return_value=[mock_ep]):
104
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
105
105
  with pytest.warns(UserWarning, match="failed to load"):
106
106
  agents = load_agents()
107
107
  assert "bad" not in agents
@@ -8,11 +8,17 @@ 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,
21
+ main,
16
22
  cmd_conjure,
17
23
  cmd_invoke,
18
24
  cmd_list,
@@ -21,6 +27,19 @@ from familiar.cli import (
21
27
  )
22
28
 
23
29
 
30
+ class TestVersion:
31
+ """tests for --version flag."""
32
+
33
+ def test_version_prints_and_exits(self, capsys):
34
+ with pytest.raises(SystemExit, match="0"):
35
+ with patch("sys.argv", ["familiar", "--version"]):
36
+ main()
37
+ captured = capsys.readouterr()
38
+ assert "familiar" in captured.out
39
+ # version string should look like a semver
40
+ assert any(c.isdigit() for c in captured.out)
41
+
42
+
24
43
  class TestCreateWorktree:
25
44
  """tests for git worktree creation."""
26
45
 
@@ -70,6 +89,50 @@ class TestFindRepoRoot:
70
89
  assert result == subdir.resolve()
71
90
 
72
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
+
73
136
  class TestWriteInstruction:
74
137
  """tests for writing instruction files."""
75
138
 
@@ -86,6 +149,48 @@ class TestWriteInstruction:
86
149
  assert (tmp_path / "CLAUDE.md").read_text() == "content\n"
87
150
 
88
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
+
89
194
  class TestParseKv:
90
195
  """tests for key-value parsing."""
91
196
 
@@ -329,6 +434,27 @@ class TestCmdInvoke:
329
434
  # Check that instruction file was copied
330
435
  mock_copy.assert_called_once()
331
436
 
437
+ def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
438
+ with patch("familiar.cli.create_worktree", return_value=tmp_path / "wt"):
439
+ with patch("familiar.cli.run_agent", side_effect=RuntimeError("boom")):
440
+ with patch("familiar.cli._remove_worktree") as mock_remove:
441
+ args = argparse.Namespace(
442
+ agent="claude",
443
+ invocation="explain",
444
+ into=str(tmp_path),
445
+ headless=True,
446
+ auto=False,
447
+ dry_run=False,
448
+ kv=None,
449
+ inv_args=[],
450
+ worktree=True,
451
+ save_skill=False,
452
+ skill_name=None,
453
+ )
454
+ with pytest.raises(RuntimeError, match="boom"):
455
+ cmd_invoke(args)
456
+ mock_remove.assert_called_once()
457
+
332
458
  def test_invoke_save_skill_claude(self, tmp_path):
333
459
  with patch("familiar.cli.run_agent") as mock_run:
334
460
  args = argparse.Namespace(
@@ -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:
@@ -225,7 +227,7 @@ class TestLinterPlugins:
225
227
  mock_ep.name = "invalid"
226
228
  mock_ep.load.return_value = "not a function"
227
229
 
228
- with patch("familiar.lint.entry_points", return_value=[mock_ep]):
230
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
229
231
  with pytest.warns(UserWarning, match="not callable"):
230
232
  linters = load_linters("conjurings")
231
233
  assert len(linters) == 0
@@ -236,7 +238,7 @@ class TestLinterPlugins:
236
238
  mock_ep.name = "broken"
237
239
  mock_ep.load.side_effect = ImportError("module not found")
238
240
 
239
- with patch("familiar.lint.entry_points", return_value=[mock_ep]):
241
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
240
242
  with pytest.warns(UserWarning, match="failed to load"):
241
243
  linters = load_linters("conjurings")
242
244
  assert len(linters) == 0
@@ -258,7 +260,7 @@ class TestLinterPlugins:
258
260
  templates.mkdir(parents=True)
259
261
  (templates / "mytemplate.md").write_text("# test")
260
262
 
261
- with patch("familiar.lint.entry_points") as mock_entry_points:
263
+ with patch("familiar._plugins.entry_points") as mock_entry_points:
262
264
  # Return our mock for templates, empty for invocations
263
265
  def ep_side_effect(group):
264
266
  if group == "familiar.linters.conjurings":
@@ -287,7 +289,7 @@ class TestLinterPlugins:
287
289
  templates.mkdir(parents=True)
288
290
  (templates / "test.md").write_text("# test")
289
291
 
290
- with patch("familiar.lint.entry_points") as mock_entry_points:
292
+ with patch("familiar._plugins.entry_points") as mock_entry_points:
291
293
 
292
294
  def ep_side_effect(group):
293
295
  if group == "familiar.linters.conjurings":
@@ -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."""
@@ -121,6 +140,34 @@ class TestComposeSystem:
121
140
  # verify order: core, then conjurings in order
122
141
  assert system == "CORE\n\nFIRST\n\nSECOND"
123
142
 
143
+ def test_compose_core_only(self, tmp_path):
144
+ templates = tmp_path / ".familiar" / "conjurings"
145
+ templates.mkdir(parents=True)
146
+ (templates / "core.md").write_text("CORE")
147
+
148
+ system = compose_system(tmp_path, [])
149
+ assert system == "CORE"
150
+
151
+ def test_compose_multiple_conjurings(self, tmp_path):
152
+ templates = tmp_path / ".familiar" / "conjurings"
153
+ templates.mkdir(parents=True)
154
+ (templates / "core.md").write_text("CORE")
155
+ (templates / "a.md").write_text("A")
156
+ (templates / "b.md").write_text("B")
157
+ (templates / "c.md").write_text("C")
158
+
159
+ system = compose_system(tmp_path, ["a", "b", "c"])
160
+ assert system == "CORE\n\nA\n\nB\n\nC"
161
+
162
+ def test_compose_strips_whitespace(self, tmp_path):
163
+ templates = tmp_path / ".familiar" / "conjurings"
164
+ templates.mkdir(parents=True)
165
+ (templates / "core.md").write_text(" CORE \n")
166
+ (templates / "pad.md").write_text("\n PAD \n\n")
167
+
168
+ system = compose_system(tmp_path, ["pad"])
169
+ assert system == "CORE\n\nPAD"
170
+
124
171
  def test_compose_missing_profile_raises(self, tmp_path):
125
172
  with pytest.raises(NotFoundError, match="unknown conjuring"):
126
173
  compose_system(tmp_path, ["nonexistent"])
@@ -137,6 +184,36 @@ class TestRenderInvocation:
137
184
  result = render_invocation(tmp_path, "greet", ["world"], {"style": "friendly"})
138
185
  assert result == "hello world, friendly"
139
186
 
187
+ def test_render_with_snippet_and_args(self, tmp_path):
188
+ snippets = tmp_path / ".familiar" / "snippets" / "test"
189
+ snippets.mkdir(parents=True)
190
+ (snippets / "header.txt").write_text("== $1 ==")
191
+
192
+ invocations = tmp_path / ".familiar" / "invocations"
193
+ invocations.mkdir(parents=True)
194
+ (invocations / "demo.md").write_text(
195
+ "{{> snippet:test/header.txt}}\nbody: {{mode}}"
196
+ )
197
+
198
+ result = render_invocation(tmp_path, "demo", ["title"], {"mode": "fast"})
199
+ assert result == "== title ==\nbody: fast"
200
+
201
+ def test_render_no_args(self, tmp_path):
202
+ invocations = tmp_path / ".familiar" / "invocations"
203
+ invocations.mkdir(parents=True)
204
+ (invocations / "plain.md").write_text("no placeholders here")
205
+
206
+ result = render_invocation(tmp_path, "plain", [], {})
207
+ assert result == "no placeholders here"
208
+
209
+ def test_render_arguments_placeholder(self, tmp_path):
210
+ invocations = tmp_path / ".familiar" / "invocations"
211
+ invocations.mkdir(parents=True)
212
+ (invocations / "all.md").write_text("run $ARGUMENTS")
213
+
214
+ result = render_invocation(tmp_path, "all", ["a", "b", "c"], {})
215
+ assert result == "run a b c"
216
+
140
217
  def test_render_missing_invocation_raises(self, tmp_path):
141
218
  with pytest.raises(NotFoundError, match="unknown invocation"):
142
219
  render_invocation(tmp_path, "nonexistent", [], {})
@@ -204,6 +281,32 @@ class TestListItems:
204
281
  items = list_items(tmp_path, "conjurings")
205
282
  assert len(items) > 0
206
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
+
207
310
 
208
311
  class TestLoadSnippet:
209
312
  """tests for loading snippets."""
@@ -231,6 +334,25 @@ class TestLoadSnippet:
231
334
  with pytest.raises(NotFoundError, match="unknown snippet"):
232
335
  load_snippet(tmp_path, "nonexistent/file.txt")
233
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
+
234
356
 
235
357
  class TestResolveIncludes:
236
358
  """tests for snippet include resolution."""
@@ -259,6 +381,15 @@ class TestResolveIncludes:
259
381
  with pytest.raises(NotFoundError, match="unknown snippet"):
260
382
  resolve_includes(tmp_path, text)
261
383
 
384
+ def test_nonexistent_among_valid_raises(self, tmp_path):
385
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
386
+ snippet_dir.mkdir(parents=True)
387
+ (snippet_dir / "good.txt").write_text("ok")
388
+
389
+ text = "{{> snippet:test/good.txt}} {{> snippet:test/missing.txt}}"
390
+ with pytest.raises(NotFoundError, match="unknown snippet"):
391
+ resolve_includes(tmp_path, text)
392
+
262
393
  def test_include_before_substitute(self, tmp_path):
263
394
  """Include resolution happens before placeholder substitution."""
264
395
  snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
@@ -325,3 +456,36 @@ class TestListSnippets:
325
456
  _, first_line, is_local = custom_items[0]
326
457
  assert is_local is True
327
458
  assert first_line == "first line here"
459
+
460
+ def test_local_override_preserves_other_builtins(self, tmp_path):
461
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
462
+ snippet_dir.mkdir(parents=True)
463
+ (snippet_dir / "pyproject.toml").write_text("custom")
464
+
465
+ items = list_snippets(tmp_path)
466
+ lookup = {p: (f, loc) for p, f, loc in items}
467
+ assert lookup["python/pyproject.toml"][1] is True
468
+ assert "rust/Cargo.toml" in lookup
469
+ assert lookup["rust/Cargo.toml"][1] is False
470
+
471
+ def test_local_underscore_files_excluded(self, tmp_path):
472
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
473
+ snippet_dir.mkdir(parents=True)
474
+ (snippet_dir / "_hidden.txt").write_text("should not appear")
475
+ (snippet_dir / "visible.txt").write_text("should appear")
476
+
477
+ items = list_snippets(tmp_path)
478
+ paths = [p for p, _, _ in items]
479
+ assert "test/visible.txt" in paths
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