familiar-cli 0.3.1__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.3.1/src/familiar_cli.egg-info → familiar_cli-0.5.0}/PKG-INFO +15 -1
  2. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/pyproject.toml +1 -1
  3. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/readme.md +14 -0
  4. familiar_cli-0.5.0/src/familiar/_plugins.py +44 -0
  5. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/agents.py +38 -13
  6. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/cli.py +146 -5
  7. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/lint.py +8 -23
  8. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/render.py +60 -46
  9. {familiar_cli-0.3.1 → familiar_cli-0.5.0/src/familiar_cli.egg-info}/PKG-INFO +15 -1
  10. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
  11. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_agents.py +39 -3
  12. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_cli.py +159 -0
  13. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_lint.py +4 -4
  14. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_render.py +89 -0
  15. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/LICENSE +0 -0
  16. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/setup.cfg +0 -0
  17. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/__init__.py +0 -0
  18. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/core.md +0 -0
  19. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/data.md +0 -0
  20. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/docs.md +0 -0
  21. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/frontend.md +0 -0
  22. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/infra.md +0 -0
  23. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/python.md +0 -0
  24. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/rust.md +0 -0
  25. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/sec.md +0 -0
  26. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/__noop__.md +0 -0
  27. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-ci.md +0 -0
  28. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-tests.md +0 -0
  29. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/audit.md +0 -0
  30. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  31. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  32. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/code-review.md +0 -0
  33. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/explain.md +0 -0
  34. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/implement-feature.md +0 -0
  35. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/infra-change.md +0 -0
  36. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/performance.md +0 -0
  37. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/refactor.md +0 -0
  38. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/release.md +0 -0
  39. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/security-review.md +0 -0
  40. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  41. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  42. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  43. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  44. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  45. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  46. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  47. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  48. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/py.typed +0 -0
  49. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  50. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  51. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/requires.txt +0 -0
  52. {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
  53. {familiar_cli-0.3.1 → 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.3.1
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
@@ -66,6 +66,20 @@ conjure conjurings to create system instructions for an agent:
66
66
  familiar conjure codex rust infra sec
67
67
  ```
68
68
 
69
+ save composed conjurings as a reusable subagent:
70
+
71
+ ```bash
72
+ familiar conjure claude python sec --save-subagent
73
+ familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
74
+ ```
75
+
76
+ save an invocation as a reusable skill:
77
+
78
+ ```bash
79
+ familiar invoke claude code-review --save-skill
80
+ familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
81
+ ```
82
+
69
83
  invoke an action prompt:
70
84
 
71
85
  ```bash
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "familiar-cli"
3
- version = "0.3.1"
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"
@@ -37,6 +37,20 @@ conjure conjurings to create system instructions for an agent:
37
37
  familiar conjure codex rust infra sec
38
38
  ```
39
39
 
40
+ save composed conjurings as a reusable subagent:
41
+
42
+ ```bash
43
+ familiar conjure claude python sec --save-subagent
44
+ familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
45
+ ```
46
+
47
+ save an invocation as a reusable skill:
48
+
49
+ ```bash
50
+ familiar invoke claude code-review --save-skill
51
+ familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
52
+ ```
53
+
40
54
  invoke an action prompt:
41
55
 
42
56
  ```bash
@@ -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,15 +5,20 @@ 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."""
14
15
 
15
16
  name: str
16
17
  output_file: str
18
+ skill_dir: str | None = None
19
+ skill_file: str = "SKILL.md"
20
+ subagent_dir: str | None = None
21
+ subagent_file: str = "AGENT.md"
17
22
 
18
23
  @abstractmethod
19
24
  def run(
@@ -21,10 +26,32 @@ class Agent(ABC):
21
26
  ) -> int:
22
27
  """Run the agent with the given prompt."""
23
28
 
29
+ def supports_skills(self) -> bool:
30
+ """Whether this agent supports reusable skill files."""
31
+ return self.skill_dir is not None
32
+
33
+ def skill_path(self, repo_root: Path, skill_name: str) -> Path:
34
+ """Build the skill path for a given skill name."""
35
+ if self.skill_dir is None:
36
+ raise ValueError(f"agent does not support skills: {self.name}")
37
+ return repo_root / self.skill_dir / skill_name / self.skill_file
38
+
39
+ def supports_subagents(self) -> bool:
40
+ """Whether this agent supports reusable subagent files."""
41
+ return self.subagent_dir is not None
42
+
43
+ def subagent_path(self, repo_root: Path, subagent_name: str) -> Path:
44
+ """Build the subagent path for a given subagent name."""
45
+ if self.subagent_dir is None:
46
+ raise ValueError(f"agent does not support subagents: {self.name}")
47
+ return repo_root / self.subagent_dir / subagent_name / self.subagent_file
48
+
24
49
 
25
50
  class CodexAgent(Agent):
26
51
  name = "codex"
27
52
  output_file = "AGENTS.md"
53
+ skill_dir = ".codex/skills"
54
+ subagent_dir = ".codex/subagents"
28
55
 
29
56
  def run(
30
57
  self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
@@ -47,6 +74,8 @@ class CodexAgent(Agent):
47
74
  class ClaudeAgent(Agent):
48
75
  name = "claude"
49
76
  output_file = "CLAUDE.md"
77
+ skill_dir = ".claude/skills"
78
+ subagent_dir = ".claude/subagents"
50
79
 
51
80
  def run(
52
81
  self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
@@ -68,26 +97,22 @@ def load_agents() -> dict[str, Agent]:
68
97
  Dictionary mapping agent names to Agent instances.
69
98
  Plugins that fail to load are skipped with a warning.
70
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
+ )
71
106
  agents: dict[str, Agent] = {}
72
- eps = entry_points(group="familiar.agents")
73
-
74
- for ep in eps:
107
+ for cls in plugins:
75
108
  try:
76
- cls = ep.load()
77
- if not (isinstance(cls, type) and issubclass(cls, Agent)):
78
- warnings.warn(
79
- f"agent plugin '{ep.name}': not a valid Agent subclass",
80
- stacklevel=2,
81
- )
82
- continue
83
109
  instance = cls()
84
110
  agents[instance.name] = instance
85
111
  except Exception as e:
86
112
  warnings.warn(
87
- f"failed to load agent plugin '{ep.name}': {e}",
113
+ f"failed to load agent plugin '{cls.__name__}': {e}",
88
114
  stacklevel=2,
89
115
  )
90
-
91
116
  return agents
92
117
 
93
118
 
@@ -4,11 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import os
7
+ import re
7
8
  import shutil
8
9
  import subprocess
9
10
  import sys
10
11
  import tempfile
11
12
  import traceback
13
+ import warnings
14
+ from importlib.metadata import version
12
15
  from pathlib import Path
13
16
 
14
17
  from .agents import get_agents, get_agent
@@ -25,6 +28,7 @@ from .render import (
25
28
  EXIT_SUCCESS = 0
26
29
  EXIT_ERROR = 1 # general error (agent failed, etc.)
27
30
  EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
31
+ _VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
28
32
 
29
33
 
30
34
  class CliError(Exception):
@@ -38,6 +42,11 @@ class CliError(Exception):
38
42
  self.exit_code = exit_code
39
43
 
40
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
+
41
50
  def find_repo_root(start: Path) -> Path:
42
51
  """Find the repository root by looking for .git directory.
43
52
 
@@ -51,6 +60,25 @@ def find_repo_root(start: Path) -> Path:
51
60
  return cur
52
61
 
53
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
+
54
82
  def create_worktree(repo_root: Path) -> Path:
55
83
  """Create a temporary git worktree for the repository."""
56
84
  # Ensure it's a git repo
@@ -95,10 +123,81 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
95
123
  try:
96
124
  agent = get_agent(agent_name)
97
125
  except KeyError as e:
98
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
126
+ raise CliError(str(e), hint=_agent_hint())
99
127
  (repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
100
128
 
101
129
 
130
+ def write_skill(
131
+ repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
132
+ ) -> Path:
133
+ """Write a reusable skill file for supported agents."""
134
+ if not _VALID_NAME.match(skill_name):
135
+ raise CliError(
136
+ f"invalid skill name: {skill_name}",
137
+ hint="use lowercase letters, numbers, underscore, or hyphen",
138
+ )
139
+
140
+ try:
141
+ agent = get_agent(agent_name)
142
+ except KeyError as e:
143
+ raise CliError(str(e), hint=_agent_hint())
144
+
145
+ if not agent.supports_skills():
146
+ raise CliError(f"agent does not support skills: {agent_name}")
147
+
148
+ skill_path = agent.skill_path(repo_root, skill_name)
149
+ skill_path.parent.mkdir(parents=True, exist_ok=True)
150
+ content = (
151
+ f"# {skill_name}\n\n"
152
+ "use this skill when this recurring task appears in this repository.\n\n"
153
+ "## generated from\n\n"
154
+ f"- invocation: {invocation}\n"
155
+ f"- agent: {agent_name}\n\n"
156
+ "## instructions\n\n"
157
+ f"{prompt.strip()}\n"
158
+ )
159
+ skill_path.write_text(content, encoding="utf-8")
160
+ return skill_path
161
+
162
+
163
+ def write_subagent(
164
+ repo_root: Path,
165
+ agent_name: str,
166
+ conjurings: list[str],
167
+ subagent_name: str,
168
+ system: str,
169
+ ) -> Path:
170
+ """Write a reusable subagent file for supported agents."""
171
+ if not _VALID_NAME.match(subagent_name):
172
+ raise CliError(
173
+ f"invalid subagent name: {subagent_name}",
174
+ hint="use lowercase letters, numbers, underscore, or hyphen",
175
+ )
176
+
177
+ try:
178
+ agent = get_agent(agent_name)
179
+ except KeyError as e:
180
+ raise CliError(str(e), hint=_agent_hint())
181
+
182
+ if not agent.supports_subagents():
183
+ raise CliError(f"agent does not support subagents: {agent_name}")
184
+
185
+ subagent_path = agent.subagent_path(repo_root, subagent_name)
186
+ subagent_path.parent.mkdir(parents=True, exist_ok=True)
187
+ profile = ", ".join(conjurings)
188
+ content = (
189
+ f"# {subagent_name}\n\n"
190
+ "use this subagent when this repository needs this persistent behavior profile.\n\n"
191
+ "## generated from\n\n"
192
+ f"- conjurings: {profile}\n"
193
+ f"- agent: {agent_name}\n\n"
194
+ "## instructions\n\n"
195
+ f"{system.strip()}\n"
196
+ )
197
+ subagent_path.write_text(content, encoding="utf-8")
198
+ return subagent_path
199
+
200
+
102
201
  def parse_kv(pairs: list[str]) -> dict[str, str]:
103
202
  out: dict[str, str] = {}
104
203
  for p in pairs:
@@ -118,7 +217,7 @@ def run_agent(
118
217
  try:
119
218
  agent = get_agent(agent_name)
120
219
  except KeyError as e:
121
- raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
220
+ raise CliError(str(e), hint=_agent_hint())
122
221
  try:
123
222
  return agent.run(repo_root, prompt, headless, auto=auto)
124
223
  except FileNotFoundError:
@@ -138,6 +237,14 @@ def cmd_conjure(args: argparse.Namespace) -> int:
138
237
  hint="run 'familiar list conjurings' to see available options",
139
238
  )
140
239
  write_instruction(repo_root, args.agent, system)
240
+ if getattr(args, "save_subagent", False):
241
+ subagent_name = getattr(args, "subagent_name", None) or "_".join(
242
+ args.conjurings
243
+ )
244
+ subagent_path = write_subagent(
245
+ repo_root, args.agent, args.conjurings, subagent_name, system
246
+ )
247
+ print(f"wrote subagent: {subagent_path}")
141
248
  print(f"wrote instructions for {args.agent}")
142
249
  return EXIT_SUCCESS
143
250
 
@@ -157,6 +264,14 @@ def cmd_invoke(args: argparse.Namespace) -> int:
157
264
  print(prompt)
158
265
  return EXIT_SUCCESS
159
266
 
267
+ if getattr(args, "save_skill", False):
268
+ skill_name = getattr(args, "skill_name", None) or args.invocation
269
+ skill_path = write_skill(
270
+ repo_root, args.agent, args.invocation, skill_name, prompt
271
+ )
272
+ print(f"wrote skill: {skill_path}")
273
+ return EXIT_SUCCESS
274
+
160
275
  target_dir = repo_root
161
276
  if args.worktree:
162
277
  target_dir = create_worktree(repo_root)
@@ -168,8 +283,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
168
283
  instr_file = repo_root / agent.output_file
169
284
  if instr_file.exists():
170
285
  shutil.copy2(instr_file, target_dir / agent.output_file)
171
- except (OSError, KeyError):
172
- pass
286
+ except (OSError, KeyError) as exc:
287
+ warnings.warn(
288
+ f"failed to copy instruction file into worktree: {exc}",
289
+ stacklevel=2,
290
+ )
173
291
 
174
292
  try:
175
293
  rc = run_agent(
@@ -181,7 +299,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
181
299
  return rc
182
300
  except Exception:
183
301
  if args.worktree:
184
- print(f"\nworktree remains at: {target_dir}")
302
+ _remove_worktree(repo_root, target_dir)
185
303
  raise
186
304
 
187
305
 
@@ -267,6 +385,11 @@ def main() -> None:
267
385
  " familiar lint # validate prompts\n",
268
386
  formatter_class=argparse.RawDescriptionHelpFormatter,
269
387
  )
388
+ parser.add_argument(
389
+ "--version",
390
+ action="version",
391
+ version=f"%(prog)s {version('familiar-cli')}",
392
+ )
270
393
  parser.add_argument(
271
394
  "--debug", action="store_true", help="show full traceback on error"
272
395
  )
@@ -285,6 +408,15 @@ def main() -> None:
285
408
  "conjurings", nargs="+", help="conjuring names, e.g. rust infra sec"
286
409
  )
287
410
  conjure.add_argument("--into", help="target repo path (default: current directory)")
411
+ conjure.add_argument(
412
+ "--save-subagent",
413
+ action="store_true",
414
+ help="write composed conjurings as a reusable subagent",
415
+ )
416
+ conjure.add_argument(
417
+ "--subagent-name",
418
+ help="override subagent name (default: joined conjuring names)",
419
+ )
288
420
  conjure.set_defaults(func=cmd_conjure)
289
421
 
290
422
  invoke = sub.add_parser(
@@ -308,6 +440,15 @@ def main() -> None:
308
440
  invoke.add_argument(
309
441
  "--worktree", action="store_true", help="run in a separate git worktree"
310
442
  )
443
+ invoke.add_argument(
444
+ "--save-skill",
445
+ action="store_true",
446
+ help="write rendered invocation as a skill and exit",
447
+ )
448
+ invoke.add_argument(
449
+ "--skill-name",
450
+ help="override skill name (default: invocation name)",
451
+ )
311
452
  invoke.add_argument("--kv", nargs="*", help="named arguments as key=value pairs")
312
453
  invoke.add_argument(
313
454
  "inv_args", nargs="*", help="positional arguments for the invocation"
@@ -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.3.1
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
@@ -66,6 +66,20 @@ conjure conjurings to create system instructions for an agent:
66
66
  familiar conjure codex rust infra sec
67
67
  ```
68
68
 
69
+ save composed conjurings as a reusable subagent:
70
+
71
+ ```bash
72
+ familiar conjure claude python sec --save-subagent
73
+ familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
74
+ ```
75
+
76
+ save an invocation as a reusable skill:
77
+
78
+ ```bash
79
+ familiar invoke claude code-review --save-skill
80
+ familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
81
+ ```
82
+
69
83
  invoke an action prompt:
70
84
 
71
85
  ```bash
@@ -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
@@ -118,6 +118,24 @@ class TestCodexAgent:
118
118
  agent = CodexAgent()
119
119
  assert agent.output_file == "AGENTS.md"
120
120
 
121
+ def test_supports_skills(self):
122
+ agent = CodexAgent()
123
+ assert agent.supports_skills() is True
124
+
125
+ def test_skill_path(self, tmp_path):
126
+ agent = CodexAgent()
127
+ path = agent.skill_path(tmp_path, "refactor")
128
+ assert path == tmp_path / ".codex" / "skills" / "refactor" / "SKILL.md"
129
+
130
+ def test_supports_subagents(self):
131
+ agent = CodexAgent()
132
+ assert agent.supports_subagents() is True
133
+
134
+ def test_subagent_path(self, tmp_path):
135
+ agent = CodexAgent()
136
+ path = agent.subagent_path(tmp_path, "python_sec")
137
+ assert path == tmp_path / ".codex" / "subagents" / "python_sec" / "AGENT.md"
138
+
121
139
  def test_run_headless(self, tmp_path):
122
140
  agent = CodexAgent()
123
141
  mock_result = MagicMock()
@@ -173,6 +191,24 @@ class TestClaudeAgent:
173
191
  agent = ClaudeAgent()
174
192
  assert agent.output_file == "CLAUDE.md"
175
193
 
194
+ def test_supports_skills(self):
195
+ agent = ClaudeAgent()
196
+ assert agent.supports_skills() is True
197
+
198
+ def test_skill_path(self, tmp_path):
199
+ agent = ClaudeAgent()
200
+ path = agent.skill_path(tmp_path, "review")
201
+ assert path == tmp_path / ".claude" / "skills" / "review" / "SKILL.md"
202
+
203
+ def test_supports_subagents(self):
204
+ agent = ClaudeAgent()
205
+ assert agent.supports_subagents() is True
206
+
207
+ def test_subagent_path(self, tmp_path):
208
+ agent = ClaudeAgent()
209
+ path = agent.subagent_path(tmp_path, "python_sec")
210
+ assert path == tmp_path / ".claude" / "subagents" / "python_sec" / "AGENT.md"
211
+
176
212
  def test_run_headless(self, tmp_path):
177
213
  agent = ClaudeAgent()
178
214
 
@@ -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
 
@@ -132,6 +146,8 @@ class TestCmdConjure:
132
146
  agent="claude",
133
147
  conjurings=["python"],
134
148
  into=str(tmp_path),
149
+ save_subagent=False,
150
+ subagent_name=None,
135
151
  )
136
152
  result = cmd_conjure(args)
137
153
  assert result == 0
@@ -144,10 +160,55 @@ class TestCmdConjure:
144
160
  agent="claude",
145
161
  conjurings=["nonexistent"],
146
162
  into=str(tmp_path),
163
+ save_subagent=False,
164
+ subagent_name=None,
147
165
  )
148
166
  with pytest.raises(CliError, match="unknown conjuring"):
149
167
  cmd_conjure(args)
150
168
 
169
+ def test_writes_subagent_for_claude(self, tmp_path):
170
+ args = argparse.Namespace(
171
+ agent="claude",
172
+ conjurings=["python", "sec"],
173
+ into=str(tmp_path),
174
+ save_subagent=True,
175
+ subagent_name=None,
176
+ )
177
+ result = cmd_conjure(args)
178
+ assert result == 0
179
+ subagent = tmp_path / ".claude" / "subagents" / "python_sec" / "AGENT.md"
180
+ assert subagent.exists()
181
+ content = subagent.read_text()
182
+ assert "conjurings: python, sec" in content
183
+ assert "agent: claude" in content
184
+
185
+ def test_writes_subagent_for_codex_with_custom_name(self, tmp_path):
186
+ args = argparse.Namespace(
187
+ agent="codex",
188
+ conjurings=["rust", "infra"],
189
+ into=str(tmp_path),
190
+ save_subagent=True,
191
+ subagent_name="ship_ops",
192
+ )
193
+ result = cmd_conjure(args)
194
+ assert result == 0
195
+ subagent = tmp_path / ".codex" / "subagents" / "ship_ops" / "AGENT.md"
196
+ assert subagent.exists()
197
+ content = subagent.read_text()
198
+ assert "conjurings: rust, infra" in content
199
+ assert "agent: codex" in content
200
+
201
+ def test_invalid_subagent_name_raises(self, tmp_path):
202
+ args = argparse.Namespace(
203
+ agent="claude",
204
+ conjurings=["python"],
205
+ into=str(tmp_path),
206
+ save_subagent=True,
207
+ subagent_name="Not Valid",
208
+ )
209
+ with pytest.raises(CliError, match="invalid subagent name"):
210
+ cmd_conjure(args)
211
+
151
212
 
152
213
  class TestCmdInvoke:
153
214
  """tests for invoke command."""
@@ -164,6 +225,8 @@ class TestCmdInvoke:
164
225
  kv=None,
165
226
  inv_args=["some code"],
166
227
  worktree=False,
228
+ save_skill=False,
229
+ skill_name=None,
167
230
  )
168
231
  result = cmd_invoke(args)
169
232
  assert result == 0
@@ -183,6 +246,8 @@ class TestCmdInvoke:
183
246
  kv=None,
184
247
  inv_args=[],
185
248
  worktree=False,
249
+ save_skill=False,
250
+ skill_name=None,
186
251
  )
187
252
  with pytest.raises(CliError, match="unknown invocation"):
188
253
  cmd_invoke(args)
@@ -204,6 +269,8 @@ class TestCmdInvoke:
204
269
  kv=["mykey=myvalue"],
205
270
  inv_args=[],
206
271
  worktree=False,
272
+ save_skill=False,
273
+ skill_name=None,
207
274
  )
208
275
  cmd_invoke(args)
209
276
  prompt = mock_run.call_args[0][2]
@@ -220,6 +287,8 @@ class TestCmdInvoke:
220
287
  kv=None,
221
288
  inv_args=["some code"],
222
289
  worktree=False,
290
+ save_skill=False,
291
+ skill_name=None,
223
292
  )
224
293
  result = cmd_invoke(args)
225
294
  assert result == 0
@@ -238,6 +307,8 @@ class TestCmdInvoke:
238
307
  kv=None,
239
308
  inv_args=[],
240
309
  worktree=False,
310
+ save_skill=False,
311
+ skill_name=None,
241
312
  )
242
313
  cmd_invoke(args)
243
314
  mock_run.assert_not_called()
@@ -261,6 +332,8 @@ class TestCmdInvoke:
261
332
  kv=None,
262
333
  inv_args=[],
263
334
  worktree=True,
335
+ save_skill=False,
336
+ skill_name=None,
264
337
  )
265
338
  result = cmd_invoke(args)
266
339
  assert result == 0
@@ -270,6 +343,92 @@ class TestCmdInvoke:
270
343
  # Check that instruction file was copied
271
344
  mock_copy.assert_called_once()
272
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
+
367
+ def test_invoke_save_skill_claude(self, tmp_path):
368
+ with patch("familiar.cli.run_agent") as mock_run:
369
+ args = argparse.Namespace(
370
+ agent="claude",
371
+ invocation="explain",
372
+ into=str(tmp_path),
373
+ headless=True,
374
+ auto=False,
375
+ dry_run=False,
376
+ kv=None,
377
+ inv_args=["some code"],
378
+ worktree=False,
379
+ save_skill=True,
380
+ skill_name=None,
381
+ )
382
+ result = cmd_invoke(args)
383
+ assert result == 0
384
+ skill = tmp_path / ".claude" / "skills" / "explain" / "SKILL.md"
385
+ assert skill.exists()
386
+ content = skill.read_text()
387
+ assert "invocation: explain" in content
388
+ assert "agent: claude" in content
389
+ mock_run.assert_not_called()
390
+
391
+ def test_invoke_save_skill_codex_custom_name(self, tmp_path):
392
+ with patch("familiar.cli.run_agent") as mock_run:
393
+ args = argparse.Namespace(
394
+ agent="codex",
395
+ invocation="refactor",
396
+ into=str(tmp_path),
397
+ headless=True,
398
+ auto=False,
399
+ dry_run=False,
400
+ kv=None,
401
+ inv_args=["src/foo.py"],
402
+ worktree=False,
403
+ save_skill=True,
404
+ skill_name="cleanup_refactor",
405
+ )
406
+ result = cmd_invoke(args)
407
+ assert result == 0
408
+ skill = tmp_path / ".codex" / "skills" / "cleanup_refactor" / "SKILL.md"
409
+ assert skill.exists()
410
+ content = skill.read_text()
411
+ assert "invocation: refactor" in content
412
+ assert "agent: codex" in content
413
+ mock_run.assert_not_called()
414
+
415
+ def test_invoke_save_skill_invalid_name(self, tmp_path):
416
+ args = argparse.Namespace(
417
+ agent="claude",
418
+ invocation="explain",
419
+ into=str(tmp_path),
420
+ headless=True,
421
+ auto=False,
422
+ dry_run=False,
423
+ kv=None,
424
+ inv_args=["some code"],
425
+ worktree=False,
426
+ save_skill=True,
427
+ skill_name="Not Valid",
428
+ )
429
+ with pytest.raises(CliError, match="invalid skill name"):
430
+ cmd_invoke(args)
431
+
273
432
 
274
433
  class TestCmdList:
275
434
  """tests for list command."""
@@ -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