familiar-cli 0.4.0__tar.gz → 0.5.0__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.0}/PKG-INFO +1 -1
  2. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/pyproject.toml +1 -1
  3. familiar_cli-0.5.0/src/familiar/_plugins.py +44 -0
  4. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/agents.py +10 -13
  5. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/cli.py +44 -11
  6. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/lint.py +8 -23
  7. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/render.py +60 -46
  8. {familiar_cli-0.4.0 → familiar_cli-0.5.0/src/familiar_cli.egg-info}/PKG-INFO +1 -1
  9. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
  10. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_agents.py +3 -3
  11. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_cli.py +35 -0
  12. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_lint.py +4 -4
  13. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_render.py +89 -0
  14. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/LICENSE +0 -0
  15. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/readme.md +0 -0
  16. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/setup.cfg +0 -0
  17. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/__init__.py +0 -0
  18. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/core.md +0 -0
  19. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/data.md +0 -0
  20. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/docs.md +0 -0
  21. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/frontend.md +0 -0
  22. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/infra.md +0 -0
  23. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/python.md +0 -0
  24. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/rust.md +0 -0
  25. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/sec.md +0 -0
  26. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/__noop__.md +0 -0
  27. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-ci.md +0 -0
  28. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-tests.md +0 -0
  29. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/audit.md +0 -0
  30. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  31. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  32. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/code-review.md +0 -0
  33. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/explain.md +0 -0
  34. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/implement-feature.md +0 -0
  35. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/infra-change.md +0 -0
  36. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/performance.md +0 -0
  37. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/refactor.md +0 -0
  38. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/release.md +0 -0
  39. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/security-review.md +0 -0
  40. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  41. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  42. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  43. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  44. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  45. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  46. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  47. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  48. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/py.typed +0 -0
  49. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  50. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  51. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/requires.txt +0 -0
  52. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
  53. {familiar_cli-0.4.0 → familiar_cli-0.5.0}/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.0
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.0"
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,6 +10,8 @@ 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
17
  from .agents import get_agents, get_agent
@@ -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,11 @@ 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
+
44
50
  def find_repo_root(start: Path) -> Path:
45
51
  """Find the repository root by looking for .git directory.
46
52
 
@@ -54,6 +60,25 @@ def find_repo_root(start: Path) -> Path:
54
60
  return cur
55
61
 
56
62
 
63
+ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
64
+ """Best-effort removal of a git worktree."""
65
+ try:
66
+ subprocess.run(
67
+ [
68
+ "git",
69
+ "-C",
70
+ str(repo_root),
71
+ "worktree",
72
+ "remove",
73
+ "--force",
74
+ str(worktree_path),
75
+ ],
76
+ capture_output=True,
77
+ )
78
+ except FileNotFoundError:
79
+ pass
80
+
81
+
57
82
  def create_worktree(repo_root: Path) -> Path:
58
83
  """Create a temporary git worktree for the repository."""
59
84
  # Ensure it's a git repo
@@ -98,7 +123,7 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
98
123
  try:
99
124
  agent = get_agent(agent_name)
100
125
  except KeyError as e:
101
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
126
+ raise CliError(str(e), hint=_agent_hint())
102
127
  (repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
103
128
 
104
129
 
@@ -106,7 +131,7 @@ def write_skill(
106
131
  repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
107
132
  ) -> Path:
108
133
  """Write a reusable skill file for supported agents."""
109
- if not _VALID_SKILL_NAME.match(skill_name):
134
+ if not _VALID_NAME.match(skill_name):
110
135
  raise CliError(
111
136
  f"invalid skill name: {skill_name}",
112
137
  hint="use lowercase letters, numbers, underscore, or hyphen",
@@ -115,7 +140,7 @@ def write_skill(
115
140
  try:
116
141
  agent = get_agent(agent_name)
117
142
  except KeyError as e:
118
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
143
+ raise CliError(str(e), hint=_agent_hint())
119
144
 
120
145
  if not agent.supports_skills():
121
146
  raise CliError(f"agent does not support skills: {agent_name}")
@@ -143,7 +168,7 @@ def write_subagent(
143
168
  system: str,
144
169
  ) -> Path:
145
170
  """Write a reusable subagent file for supported agents."""
146
- if not _VALID_SUBAGENT_NAME.match(subagent_name):
171
+ if not _VALID_NAME.match(subagent_name):
147
172
  raise CliError(
148
173
  f"invalid subagent name: {subagent_name}",
149
174
  hint="use lowercase letters, numbers, underscore, or hyphen",
@@ -152,7 +177,7 @@ def write_subagent(
152
177
  try:
153
178
  agent = get_agent(agent_name)
154
179
  except KeyError as e:
155
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
180
+ raise CliError(str(e), hint=_agent_hint())
156
181
 
157
182
  if not agent.supports_subagents():
158
183
  raise CliError(f"agent does not support subagents: {agent_name}")
@@ -192,7 +217,7 @@ def run_agent(
192
217
  try:
193
218
  agent = get_agent(agent_name)
194
219
  except KeyError as e:
195
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
220
+ raise CliError(str(e), hint=_agent_hint())
196
221
  try:
197
222
  return agent.run(repo_root, prompt, headless, auto=auto)
198
223
  except FileNotFoundError:
@@ -258,8 +283,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
258
283
  instr_file = repo_root / agent.output_file
259
284
  if instr_file.exists():
260
285
  shutil.copy2(instr_file, target_dir / agent.output_file)
261
- except (OSError, KeyError):
262
- pass
286
+ except (OSError, KeyError) as exc:
287
+ warnings.warn(
288
+ f"failed to copy instruction file into worktree: {exc}",
289
+ stacklevel=2,
290
+ )
263
291
 
264
292
  try:
265
293
  rc = run_agent(
@@ -271,7 +299,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
271
299
  return rc
272
300
  except Exception:
273
301
  if args.worktree:
274
- print(f"\nworktree remains at: {target_dir}")
302
+ _remove_worktree(repo_root, target_dir)
275
303
  raise
276
304
 
277
305
 
@@ -357,6 +385,11 @@ def main() -> None:
357
385
  " familiar lint # validate prompts\n",
358
386
  formatter_class=argparse.RawDescriptionHelpFormatter,
359
387
  )
388
+ parser.add_argument(
389
+ "--version",
390
+ action="version",
391
+ version=f"%(prog)s {version('familiar-cli')}",
392
+ )
360
393
  parser.add_argument(
361
394
  "--debug", action="store_true", help="show full traceback on error"
362
395
  )
@@ -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(
@@ -118,71 +118,85 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
118
118
  return items
119
119
 
120
120
 
121
- def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
122
- """List available snippets.
123
-
124
- Returns list of (path, first_line, is_local) tuples, sorted by path.
121
+ def _list_resources(
122
+ repo_root: Path,
123
+ pkg: str,
124
+ local_subdir: str,
125
+ *,
126
+ recursive: bool = False,
127
+ suffix: str = "",
128
+ ) -> list[tuple[str, str, bool]]:
129
+ """List resources from package data and local overrides.
130
+
131
+ Items from local ``.familiar/{local_subdir}/`` override package builtins.
132
+
133
+ Args:
134
+ repo_root: Repository root path.
135
+ pkg: Package name to scan for builtins.
136
+ local_subdir: Subdirectory name under ``.familiar/`` for local overrides.
137
+ recursive: If True, scan recursively (for nested resources like snippets).
138
+ suffix: File suffix filter (e.g. ``".md"``). Empty string matches all files.
139
+
140
+ Returns:
141
+ Sorted list of ``(key, first_line, is_local)`` tuples.
125
142
  """
126
143
  items: dict[str, tuple[str, bool]] = {}
127
144
 
128
- # built-ins
145
+ # built-in resources from package data
129
146
  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)
147
+ pkg_root = resources.files(pkg)
148
+ if recursive:
149
+ for rel, first_line in _walk_traversable(pkg_root):
150
+ items[rel] = (first_line, False)
151
+ else:
152
+ for item in pkg_root.iterdir():
153
+ if suffix and not item.name.endswith(suffix):
154
+ continue
155
+ if item.name.startswith("_"):
156
+ continue
157
+ key = item.name[: -len(suffix)] if suffix else item.name
158
+ content = item.read_text(encoding="utf-8")
159
+ first_line = content.split("\n", 1)[0].strip()
160
+ items[key] = (first_line, False)
133
161
  except (FileNotFoundError, TypeError, ModuleNotFoundError):
134
162
  pass
135
163
 
136
164
  # local overrides
137
- local_dir = repo_root / ".familiar" / "snippets"
165
+ local_dir = repo_root / ".familiar" / local_subdir
138
166
  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))
142
- content = f.read_text(encoding="utf-8")
143
- first_line = content.split("\n", 1)[0].strip()
144
- items[rel] = (first_line, True)
167
+ files = (
168
+ sorted(local_dir.rglob("*")) if recursive else local_dir.glob(f"*{suffix}")
169
+ )
170
+ for f in files:
171
+ if not f.is_file() or f.name.startswith("_"):
172
+ continue
173
+ key = str(f.relative_to(local_dir)) if recursive else f.stem
174
+ content = f.read_text(encoding="utf-8")
175
+ first_line = content.split("\n", 1)[0].strip()
176
+ items[key] = (first_line, True)
145
177
 
146
178
  return [
147
- (path, first_line, is_local)
148
- for path, (first_line, is_local) in sorted(items.items())
179
+ (key, first_line, is_local)
180
+ for key, (first_line, is_local) in sorted(items.items())
149
181
  ]
150
182
 
151
183
 
152
- def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
153
- """List available conjurings or invocations.
184
+ def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
185
+ """List available snippets.
154
186
 
155
- Returns list of (name, first_line, is_local) tuples, sorted by name.
187
+ Returns list of (path, first_line, is_local) tuples, sorted by path.
156
188
  """
157
- items: dict[str, tuple[str, bool]] = {}
189
+ return _list_resources(
190
+ repo_root, "familiar.data.snippets", "snippets", recursive=True
191
+ )
158
192
 
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
193
 
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)
194
+ def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
195
+ """List available conjurings or invocations.
181
196
 
182
- return [
183
- (name, first_line, is_local)
184
- for name, (first_line, is_local) in sorted(items.items())
185
- ]
197
+ Returns list of (name, first_line, is_local) tuples, sorted by name.
198
+ """
199
+ return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
186
200
 
187
201
 
188
202
  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.0
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
@@ -13,6 +13,7 @@ from familiar.cli import (
13
13
  write_instruction,
14
14
  parse_kv,
15
15
  run_agent,
16
+ main,
16
17
  cmd_conjure,
17
18
  cmd_invoke,
18
19
  cmd_list,
@@ -21,6 +22,19 @@ from familiar.cli import (
21
22
  )
22
23
 
23
24
 
25
+ class TestVersion:
26
+ """tests for --version flag."""
27
+
28
+ def test_version_prints_and_exits(self, capsys):
29
+ with pytest.raises(SystemExit, match="0"):
30
+ with patch("sys.argv", ["familiar", "--version"]):
31
+ main()
32
+ captured = capsys.readouterr()
33
+ assert "familiar" in captured.out
34
+ # version string should look like a semver
35
+ assert any(c.isdigit() for c in captured.out)
36
+
37
+
24
38
  class TestCreateWorktree:
25
39
  """tests for git worktree creation."""
26
40
 
@@ -329,6 +343,27 @@ class TestCmdInvoke:
329
343
  # Check that instruction file was copied
330
344
  mock_copy.assert_called_once()
331
345
 
346
+ def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
347
+ with patch("familiar.cli.create_worktree", return_value=tmp_path / "wt"):
348
+ with patch("familiar.cli.run_agent", side_effect=RuntimeError("boom")):
349
+ with patch("familiar.cli._remove_worktree") as mock_remove:
350
+ args = argparse.Namespace(
351
+ agent="claude",
352
+ invocation="explain",
353
+ into=str(tmp_path),
354
+ headless=True,
355
+ auto=False,
356
+ dry_run=False,
357
+ kv=None,
358
+ inv_args=[],
359
+ worktree=True,
360
+ save_skill=False,
361
+ skill_name=None,
362
+ )
363
+ with pytest.raises(RuntimeError, match="boom"):
364
+ cmd_invoke(args)
365
+ mock_remove.assert_called_once()
366
+
332
367
  def test_invoke_save_skill_claude(self, tmp_path):
333
368
  with patch("familiar.cli.run_agent") as mock_run:
334
369
  args = argparse.Namespace(
@@ -225,7 +225,7 @@ class TestLinterPlugins:
225
225
  mock_ep.name = "invalid"
226
226
  mock_ep.load.return_value = "not a function"
227
227
 
228
- with patch("familiar.lint.entry_points", return_value=[mock_ep]):
228
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
229
229
  with pytest.warns(UserWarning, match="not callable"):
230
230
  linters = load_linters("conjurings")
231
231
  assert len(linters) == 0
@@ -236,7 +236,7 @@ class TestLinterPlugins:
236
236
  mock_ep.name = "broken"
237
237
  mock_ep.load.side_effect = ImportError("module not found")
238
238
 
239
- with patch("familiar.lint.entry_points", return_value=[mock_ep]):
239
+ with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
240
240
  with pytest.warns(UserWarning, match="failed to load"):
241
241
  linters = load_linters("conjurings")
242
242
  assert len(linters) == 0
@@ -258,7 +258,7 @@ class TestLinterPlugins:
258
258
  templates.mkdir(parents=True)
259
259
  (templates / "mytemplate.md").write_text("# test")
260
260
 
261
- with patch("familiar.lint.entry_points") as mock_entry_points:
261
+ with patch("familiar._plugins.entry_points") as mock_entry_points:
262
262
  # Return our mock for templates, empty for invocations
263
263
  def ep_side_effect(group):
264
264
  if group == "familiar.linters.conjurings":
@@ -287,7 +287,7 @@ class TestLinterPlugins:
287
287
  templates.mkdir(parents=True)
288
288
  (templates / "test.md").write_text("# test")
289
289
 
290
- with patch("familiar.lint.entry_points") as mock_entry_points:
290
+ with patch("familiar._plugins.entry_points") as mock_entry_points:
291
291
 
292
292
  def ep_side_effect(group):
293
293
  if group == "familiar.linters.conjurings":
@@ -121,6 +121,34 @@ class TestComposeSystem:
121
121
  # verify order: core, then conjurings in order
122
122
  assert system == "CORE\n\nFIRST\n\nSECOND"
123
123
 
124
+ def test_compose_core_only(self, tmp_path):
125
+ templates = tmp_path / ".familiar" / "conjurings"
126
+ templates.mkdir(parents=True)
127
+ (templates / "core.md").write_text("CORE")
128
+
129
+ system = compose_system(tmp_path, [])
130
+ assert system == "CORE"
131
+
132
+ def test_compose_multiple_conjurings(self, tmp_path):
133
+ templates = tmp_path / ".familiar" / "conjurings"
134
+ templates.mkdir(parents=True)
135
+ (templates / "core.md").write_text("CORE")
136
+ (templates / "a.md").write_text("A")
137
+ (templates / "b.md").write_text("B")
138
+ (templates / "c.md").write_text("C")
139
+
140
+ system = compose_system(tmp_path, ["a", "b", "c"])
141
+ assert system == "CORE\n\nA\n\nB\n\nC"
142
+
143
+ def test_compose_strips_whitespace(self, tmp_path):
144
+ templates = tmp_path / ".familiar" / "conjurings"
145
+ templates.mkdir(parents=True)
146
+ (templates / "core.md").write_text(" CORE \n")
147
+ (templates / "pad.md").write_text("\n PAD \n\n")
148
+
149
+ system = compose_system(tmp_path, ["pad"])
150
+ assert system == "CORE\n\nPAD"
151
+
124
152
  def test_compose_missing_profile_raises(self, tmp_path):
125
153
  with pytest.raises(NotFoundError, match="unknown conjuring"):
126
154
  compose_system(tmp_path, ["nonexistent"])
@@ -137,6 +165,36 @@ class TestRenderInvocation:
137
165
  result = render_invocation(tmp_path, "greet", ["world"], {"style": "friendly"})
138
166
  assert result == "hello world, friendly"
139
167
 
168
+ def test_render_with_snippet_and_args(self, tmp_path):
169
+ snippets = tmp_path / ".familiar" / "snippets" / "test"
170
+ snippets.mkdir(parents=True)
171
+ (snippets / "header.txt").write_text("== $1 ==")
172
+
173
+ invocations = tmp_path / ".familiar" / "invocations"
174
+ invocations.mkdir(parents=True)
175
+ (invocations / "demo.md").write_text(
176
+ "{{> snippet:test/header.txt}}\nbody: {{mode}}"
177
+ )
178
+
179
+ result = render_invocation(tmp_path, "demo", ["title"], {"mode": "fast"})
180
+ assert result == "== title ==\nbody: fast"
181
+
182
+ def test_render_no_args(self, tmp_path):
183
+ invocations = tmp_path / ".familiar" / "invocations"
184
+ invocations.mkdir(parents=True)
185
+ (invocations / "plain.md").write_text("no placeholders here")
186
+
187
+ result = render_invocation(tmp_path, "plain", [], {})
188
+ assert result == "no placeholders here"
189
+
190
+ def test_render_arguments_placeholder(self, tmp_path):
191
+ invocations = tmp_path / ".familiar" / "invocations"
192
+ invocations.mkdir(parents=True)
193
+ (invocations / "all.md").write_text("run $ARGUMENTS")
194
+
195
+ result = render_invocation(tmp_path, "all", ["a", "b", "c"], {})
196
+ assert result == "run a b c"
197
+
140
198
  def test_render_missing_invocation_raises(self, tmp_path):
141
199
  with pytest.raises(NotFoundError, match="unknown invocation"):
142
200
  render_invocation(tmp_path, "nonexistent", [], {})
@@ -259,6 +317,15 @@ class TestResolveIncludes:
259
317
  with pytest.raises(NotFoundError, match="unknown snippet"):
260
318
  resolve_includes(tmp_path, text)
261
319
 
320
+ def test_nonexistent_among_valid_raises(self, tmp_path):
321
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
322
+ snippet_dir.mkdir(parents=True)
323
+ (snippet_dir / "good.txt").write_text("ok")
324
+
325
+ text = "{{> snippet:test/good.txt}} {{> snippet:test/missing.txt}}"
326
+ with pytest.raises(NotFoundError, match="unknown snippet"):
327
+ resolve_includes(tmp_path, text)
328
+
262
329
  def test_include_before_substitute(self, tmp_path):
263
330
  """Include resolution happens before placeholder substitution."""
264
331
  snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
@@ -325,3 +392,25 @@ class TestListSnippets:
325
392
  _, first_line, is_local = custom_items[0]
326
393
  assert is_local is True
327
394
  assert first_line == "first line here"
395
+
396
+ def test_local_override_preserves_other_builtins(self, tmp_path):
397
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
398
+ snippet_dir.mkdir(parents=True)
399
+ (snippet_dir / "pyproject.toml").write_text("custom")
400
+
401
+ items = list_snippets(tmp_path)
402
+ lookup = {p: (f, loc) for p, f, loc in items}
403
+ assert lookup["python/pyproject.toml"][1] is True
404
+ assert "rust/Cargo.toml" in lookup
405
+ assert lookup["rust/Cargo.toml"][1] is False
406
+
407
+ def test_local_underscore_files_excluded(self, tmp_path):
408
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
409
+ snippet_dir.mkdir(parents=True)
410
+ (snippet_dir / "_hidden.txt").write_text("should not appear")
411
+ (snippet_dir / "visible.txt").write_text("should appear")
412
+
413
+ items = list_snippets(tmp_path)
414
+ paths = [p for p, _, _ in items]
415
+ assert "test/visible.txt" in paths
416
+ assert "test/_hidden.txt" not in paths
File without changes
File without changes
File without changes