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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. {familiar_cli-0.5.1/src/familiar_cli.egg-info → familiar_cli-0.5.2}/PKG-INFO +1 -1
  2. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/pyproject.toml +1 -1
  3. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/_plugins.py +7 -7
  4. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/agents.py +13 -13
  5. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/cli.py +12 -17
  6. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/lint.py +31 -43
  7. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/render.py +50 -31
  8. {familiar_cli-0.5.1 → familiar_cli-0.5.2/src/familiar_cli.egg-info}/PKG-INFO +1 -1
  9. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_cli.py +0 -6
  10. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_integration.py +0 -3
  11. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_lint.py +28 -5
  12. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_render.py +79 -2
  13. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/LICENSE +0 -0
  14. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/readme.md +0 -0
  15. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/setup.cfg +0 -0
  16. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/__init__.py +0 -0
  17. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/core.md +0 -0
  18. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/data.md +0 -0
  19. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/docs.md +0 -0
  20. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/frontend.md +0 -0
  21. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/infra.md +0 -0
  22. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/python.md +0 -0
  23. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/rust.md +0 -0
  24. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/conjurings/sec.md +0 -0
  25. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/__noop__.md +0 -0
  26. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-ci.md +0 -0
  27. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-tests.md +0 -0
  28. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/audit.md +0 -0
  29. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  30. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  31. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/code-review.md +0 -0
  32. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/explain.md +0 -0
  33. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/implement-feature.md +0 -0
  34. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/infra-change.md +0 -0
  35. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/performance.md +0 -0
  36. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/refactor.md +0 -0
  37. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/release.md +0 -0
  38. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/invocations/security-review.md +0 -0
  39. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  40. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  41. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  42. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  43. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  44. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  45. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  46. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  47. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar/py.typed +0 -0
  48. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  49. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  50. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  51. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/requires.txt +0 -0
  52. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/top_level.txt +0 -0
  53. {familiar_cli-0.5.1 → familiar_cli-0.5.2}/tests/test_agents.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: compose and invoke ai agent prompts from reusable templates
5
5
  Author-email: cyberwitchery lab <contact@cyberwitchery.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "familiar-cli"
3
- version = "0.5.1"
3
+ version = "0.5.2"
4
4
  description = "compose and invoke ai agent prompts from reusable templates"
5
5
  readme = "readme.md"
6
6
  license = "MIT"
@@ -1,4 +1,4 @@
1
- """Shared plugin loading utilities."""
1
+ """shared plugin loading utilities."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,16 +14,16 @@ def load_plugins(
14
14
  label: str,
15
15
  invalid_msg: str = "validation failed",
16
16
  ) -> list[Any]:
17
- """Load plugins from entry points with validation.
17
+ """load plugins from entry points with validation.
18
18
 
19
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.
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
24
 
25
25
  Returns:
26
- List of loaded and validated plugin objects.
26
+ list of loaded and validated plugin objects.
27
27
  """
28
28
  results: list[Any] = []
29
29
  for ep in entry_points(group=group):
@@ -1,4 +1,4 @@
1
- """Agent implementations for familiar."""
1
+ """agent implementations for familiar."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -11,7 +11,7 @@ from ._plugins import load_plugins
11
11
 
12
12
 
13
13
  class Agent(ABC):
14
- """Base class for AI coding agents."""
14
+ """base class for AI coding agents."""
15
15
 
16
16
  name: str
17
17
  output_file: str
@@ -24,24 +24,24 @@ class Agent(ABC):
24
24
  def run(
25
25
  self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
26
26
  ) -> int:
27
- """Run the agent with the given prompt."""
27
+ """run the agent with the given prompt."""
28
28
 
29
29
  def supports_skills(self) -> bool:
30
- """Whether this agent supports reusable skill files."""
30
+ """whether this agent supports reusable skill files."""
31
31
  return self.skill_dir is not None
32
32
 
33
33
  def skill_path(self, repo_root: Path, skill_name: str) -> Path:
34
- """Build the skill path for a given skill name."""
34
+ """build the skill path for a given skill name."""
35
35
  if self.skill_dir is None:
36
36
  raise ValueError(f"agent does not support skills: {self.name}")
37
37
  return repo_root / self.skill_dir / skill_name / self.skill_file
38
38
 
39
39
  def supports_subagents(self) -> bool:
40
- """Whether this agent supports reusable subagent files."""
40
+ """whether this agent supports reusable subagent files."""
41
41
  return self.subagent_dir is not None
42
42
 
43
43
  def subagent_path(self, repo_root: Path, subagent_name: str) -> Path:
44
- """Build the subagent path for a given subagent name."""
44
+ """build the subagent path for a given subagent name."""
45
45
  if self.subagent_dir is None:
46
46
  raise ValueError(f"agent does not support subagents: {self.name}")
47
47
  return repo_root / self.subagent_dir / subagent_name / self.subagent_file
@@ -91,11 +91,11 @@ class ClaudeAgent(Agent):
91
91
 
92
92
 
93
93
  def load_agents() -> dict[str, Agent]:
94
- """Load all registered agent plugins via entry points.
94
+ """load all registered agent plugins via entry points.
95
95
 
96
96
  Returns:
97
- Dictionary mapping agent names to Agent instances.
98
- Plugins that fail to load are skipped with a warning.
97
+ dictionary mapping agent names to Agent instances.
98
+ plugins that fail to load are skipped with a warning.
99
99
  """
100
100
  plugins = load_plugins(
101
101
  "familiar.agents",
@@ -120,9 +120,9 @@ _agents_cache: dict[str, Agent] | None = None
120
120
 
121
121
 
122
122
  def get_agents() -> dict[str, Agent]:
123
- """Get all available agents.
123
+ """get all available agents.
124
124
 
125
- Returns a cached dictionary of agent name -> Agent instance.
125
+ returns a cached dictionary of agent name -> Agent instance.
126
126
  """
127
127
  global _agents_cache
128
128
  if _agents_cache is None:
@@ -131,7 +131,7 @@ def get_agents() -> dict[str, Agent]:
131
131
 
132
132
 
133
133
  def get_agent(name: str) -> Agent:
134
- """Get an agent by name.
134
+ """get an agent by name.
135
135
 
136
136
  Raises:
137
137
  KeyError: if the agent name is not recognized.
@@ -1,4 +1,4 @@
1
- """Command-line interface for familiar."""
1
+ """command-line interface for familiar."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -24,7 +24,6 @@ from .render import (
24
24
  render_invocation,
25
25
  )
26
26
 
27
- # exit codes
28
27
  EXIT_SUCCESS = 0
29
28
  EXIT_ERROR = 1 # general error (agent failed, etc.)
30
29
  EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
@@ -43,12 +42,12 @@ class CliError(Exception):
43
42
 
44
43
 
45
44
  def _agent_hint() -> str:
46
- """Return a hint string listing valid agent names."""
45
+ """return a hint string listing valid agent names."""
47
46
  return f"valid agents: {', '.join(get_agents().keys())}"
48
47
 
49
48
 
50
49
  def _resolve_agent(name: str) -> "Agent":
51
- """Look up an agent by name, raising CliError on unknown names."""
50
+ """look up an agent by name, raising CliError on unknown names."""
52
51
  try:
53
52
  return get_agent(name)
54
53
  except KeyError as e:
@@ -56,7 +55,7 @@ def _resolve_agent(name: str) -> "Agent":
56
55
 
57
56
 
58
57
  def _write_file(path: Path, content: str, label: str = "") -> None:
59
- """Write content to path, creating parent dirs. Wraps OSError as CliError."""
58
+ """write content to path, creating parent dirs. wraps OSError as CliError."""
60
59
  prefix = f"{label} " if label else ""
61
60
  try:
62
61
  path.parent.mkdir(parents=True, exist_ok=True)
@@ -69,9 +68,9 @@ def _write_file(path: Path, content: str, label: str = "") -> None:
69
68
 
70
69
 
71
70
  def find_repo_root(start: Path) -> Path:
72
- """Find the repository root by looking for .git directory.
71
+ """find the repository root by looking for .git directory.
73
72
 
74
- Walks up from start directory. Falls back to start directory itself
73
+ walks up from start directory. falls back to start directory itself
75
74
  if no .git is found, allowing use outside of git repositories.
76
75
  """
77
76
  cur = start.resolve()
@@ -82,7 +81,7 @@ def find_repo_root(start: Path) -> Path:
82
81
 
83
82
 
84
83
  def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
85
- """Best-effort removal of a git worktree."""
84
+ """best-effort removal of a git worktree."""
86
85
  try:
87
86
  result = subprocess.run(
88
87
  [
@@ -107,8 +106,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
107
106
 
108
107
 
109
108
  def create_worktree(repo_root: Path) -> Path:
110
- """Create a temporary git worktree for the repository."""
111
- # Ensure it's a git repo
109
+ """create a temporary git worktree for the repository."""
112
110
  try:
113
111
  subprocess.run(
114
112
  ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
@@ -122,7 +120,7 @@ def create_worktree(repo_root: Path) -> Path:
122
120
 
123
121
  tmpdir = tempfile.mkdtemp(prefix="familiar-")
124
122
  # git worktree add requires the directory to NOT exist.
125
- # mkdtemp creates it. Let's remove it and let git create it.
123
+ # mkdtemp creates it. let's remove it and let git create it.
126
124
  os.rmdir(tmpdir)
127
125
 
128
126
  try:
@@ -155,7 +153,7 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
155
153
  def write_skill(
156
154
  repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
157
155
  ) -> Path:
158
- """Write a reusable skill file for supported agents."""
156
+ """write a reusable skill file for supported agents."""
159
157
  if not _VALID_NAME.match(skill_name):
160
158
  raise CliError(
161
159
  f"invalid skill name: {skill_name}",
@@ -188,7 +186,7 @@ def write_subagent(
188
186
  subagent_name: str,
189
187
  system: str,
190
188
  ) -> Path:
191
- """Write a reusable subagent file for supported agents."""
189
+ """write a reusable subagent file for supported agents."""
192
190
  if not _VALID_NAME.match(subagent_name):
193
191
  raise CliError(
194
192
  f"invalid subagent name: {subagent_name}",
@@ -291,7 +289,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
291
289
  target_dir = create_worktree(repo_root)
292
290
  print(f"created worktree at {target_dir}")
293
291
 
294
- # Best-effort copy of instruction file into the worktree
292
+ # best-effort copy of instruction file into the worktree
295
293
  try:
296
294
  agent = get_agent(args.agent)
297
295
  instr_file = repo_root / agent.output_file
@@ -332,7 +330,6 @@ def cmd_list(args: argparse.Namespace) -> int:
332
330
  repo_root = find_repo_root(Path(args.into or os.getcwd()))
333
331
 
334
332
  if args.kind is None:
335
- # list all
336
333
  conjurings = list_items(repo_root, "conjurings")
337
334
  invocations = list_items(repo_root, "invocations")
338
335
  snippets = list_snippets(repo_root)
@@ -363,7 +360,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
363
360
 
364
361
  messages = lint_all(repo_root)
365
362
 
366
- # Filter by level if requested
367
363
  if args.errors_only:
368
364
  messages = [m for m in messages if m.level == "error"]
369
365
 
@@ -371,7 +367,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
371
367
  print("all checks passed")
372
368
  return EXIT_SUCCESS
373
369
 
374
- # Group by level for output
375
370
  errors = [m for m in messages if m.level == "error"]
376
371
  warnings = [m for m in messages if m.level == "warning"]
377
372
 
@@ -1,4 +1,4 @@
1
- """Linting for familiar conjurings and invocations."""
1
+ """linting for familiar conjurings and invocations."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -14,7 +14,7 @@ from .render import _SNIPPET_INCLUDE, NotFoundError, list_items, load_snippet, l
14
14
 
15
15
  @dataclass
16
16
  class LintMessage:
17
- """A lint message."""
17
+ """a lint message."""
18
18
 
19
19
  level: Literal["error", "warning"]
20
20
  file: str
@@ -28,17 +28,15 @@ class LintMessage:
28
28
  return f"{self.level}: {loc}: {self.message}"
29
29
 
30
30
 
31
- # Regex patterns for placeholders
32
31
  _POSITIONAL_PLACEHOLDER = re.compile(r"\$(\d+|ARGUMENTS)")
33
32
  _NAMED_PLACEHOLDER = re.compile(r"\{\{(\w+)\}\}")
34
33
 
35
- # Section patterns for invocations
36
- # Accept various task verbs as first line
34
+ # accept various task verbs as first line
37
35
  _TASK_LINE = re.compile(
38
36
  r"^(task|explain|review|analyze|check|audit|describe|create|generate|refactor|bootstrap|implement|add|fix)(\s|:)",
39
37
  re.IGNORECASE,
40
38
  )
41
- # Accept inputs, input, arguments as input sections (with or without ## heading)
39
+ # accept inputs, input, arguments as input sections (with or without ## heading)
42
40
  _INPUTS_SECTION = re.compile(
43
41
  r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE | re.MULTILINE
44
42
  )
@@ -48,10 +46,10 @@ _OUTPUT_SECTION = re.compile(
48
46
 
49
47
 
50
48
  def lint_template(content: str, name: str) -> list[LintMessage]:
51
- """Lint a template (conjuring) file.
49
+ """lint a template (conjuring) file.
52
50
 
53
- Templates should:
54
- - Start with a markdown heading
51
+ templates should:
52
+ - start with a markdown heading
55
53
  """
56
54
  messages: list[LintMessage] = []
57
55
  lines = content.split("\n")
@@ -82,13 +80,13 @@ def lint_template(content: str, name: str) -> list[LintMessage]:
82
80
 
83
81
 
84
82
  def lint_invocation(content: str, name: str) -> list[LintMessage]:
85
- """Lint an invocation file.
83
+ """lint an invocation file.
86
84
 
87
- Invocations should:
88
- - Start with a task: line (or similar verb)
89
- - Have an inputs section (warning if missing)
90
- - Have an output section (warning if missing)
91
- - Document all placeholders used
85
+ invocations should:
86
+ - start with a task: line (or similar verb)
87
+ - have an inputs section (warning if missing)
88
+ - have an output section (warning if missing)
89
+ - document all placeholders used
92
90
  """
93
91
  messages: list[LintMessage] = []
94
92
  lines = content.split("\n")
@@ -104,7 +102,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
104
102
  )
105
103
  return messages
106
104
 
107
- # Check first line is a task line
108
105
  first_line = lines[0].strip()
109
106
  if not _TASK_LINE.match(first_line):
110
107
  messages.append(
@@ -116,7 +113,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
116
113
  )
117
114
  )
118
115
 
119
- # Check for inputs section
120
116
  if not _INPUTS_SECTION.search(content):
121
117
  messages.append(
122
118
  LintMessage(
@@ -127,7 +123,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
127
123
  )
128
124
  )
129
125
 
130
- # Check for output section
131
126
  if not _OUTPUT_SECTION.search(content):
132
127
  messages.append(
133
128
  LintMessage(
@@ -138,16 +133,14 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
138
133
  )
139
134
  )
140
135
 
141
- # Find all placeholders and check if they're documented
142
136
  positional = set(_POSITIONAL_PLACEHOLDER.findall(content))
143
137
  named = set(_NAMED_PLACEHOLDER.findall(content))
144
138
 
145
- # Check if placeholders are mentioned in content (loose check)
146
- # Prefer checking the inputs section if it exists
139
+ # loose check: prefer the inputs section if it exists, else the whole file
147
140
  inputs_match = _INPUTS_SECTION.search(content)
148
141
  if inputs_match:
149
142
  start = inputs_match.end()
150
- # Look ahead for the next markdown heading or end of file
143
+ # look ahead for the next markdown heading or end of file
151
144
  next_heading = re.search(r"^#", content[start:], re.MULTILINE)
152
145
  search_area = (
153
146
  content[start : start + next_heading.start()]
@@ -158,10 +151,14 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
158
151
  search_area = content.lower()
159
152
 
160
153
  for placeholder in named:
161
- # Check if placeholder name appears in search area
162
- if placeholder.lower() not in search_area.replace(
163
- f"{{{{{placeholder.lower()}}}}}", ""
164
- ):
154
+ tag = f"{{{{{placeholder.lower()}}}}}"
155
+ stripped = search_area.replace(tag, "")
156
+ # accept the placeholder as documented if its bare name appears as a
157
+ # standalone word (after stripping {{…}} syntax) OR if the {{…}} tag
158
+ # itself appears in the inputs section (the common "- {{name}}: …"
159
+ # documentation pattern).
160
+ bare_match = re.search(r"\b" + re.escape(placeholder.lower()) + r"\b", stripped)
161
+ if not bare_match and tag not in search_area:
165
162
  messages.append(
166
163
  LintMessage(
167
164
  level="warning",
@@ -171,7 +168,6 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
171
168
  )
172
169
  )
173
170
 
174
- # Check for undocumented positional args
175
171
  for p in positional:
176
172
  if p == "ARGUMENTS":
177
173
  continue
@@ -189,18 +185,17 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
189
185
  return messages
190
186
 
191
187
 
192
- # Type alias for linter functions
193
188
  LinterFunc = Callable[[str, str], list[LintMessage]]
194
189
 
195
190
 
196
191
  def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]:
197
- """Load linter plugins for the given kind.
192
+ """load linter plugins for the given kind.
198
193
 
199
194
  Args:
200
- kind: Either "conjurings" or "invocations".
195
+ kind: either "conjurings" or "invocations".
201
196
 
202
197
  Returns:
203
- List of linter functions from plugins.
198
+ list of linter functions from plugins.
204
199
  """
205
200
  return load_plugins(
206
201
  f"familiar.linters.{kind}",
@@ -213,7 +208,7 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
213
208
  def lint_snippet_references(
214
209
  repo_root: Path, content: str, name: str
215
210
  ) -> list[LintMessage]:
216
- """Check that all snippet includes reference existing snippets."""
211
+ """check that all snippet includes reference existing snippets."""
217
212
  messages: list[LintMessage] = []
218
213
  for i, line in enumerate(content.split("\n"), 1):
219
214
  for m in _SNIPPET_INCLUDE.finditer(line):
@@ -238,7 +233,7 @@ def lint_collection(
238
233
  builtin_linter: LinterFunc,
239
234
  plugin_linters: list[LinterFunc],
240
235
  ) -> list[LintMessage]:
241
- """Lint a collection of items (conjurings or invocations)."""
236
+ """lint a collection of items (conjurings or invocations)."""
242
237
  messages: list[LintMessage] = []
243
238
  for name, _, is_local in list_items(repo_root, kind):
244
239
  try:
@@ -248,11 +243,8 @@ def lint_collection(
248
243
  if is_local
249
244
  else f"(builtin) {kind}/{name}.md"
250
245
  )
251
- # Built-in linter
252
246
  messages.extend(builtin_linter(content, prefix))
253
- # Snippet reference validation
254
247
  messages.extend(lint_snippet_references(repo_root, content, prefix))
255
- # Plugin linters
256
248
  for linter in plugin_linters:
257
249
  try:
258
250
  messages.extend(linter(content, prefix))
@@ -278,24 +270,20 @@ def lint_collection(
278
270
 
279
271
 
280
272
  def lint_all(repo_root: Path) -> list[LintMessage]:
281
- """Lint all conjurings and invocations.
273
+ """lint all conjurings and invocations.
282
274
 
283
- Runs built-in linters and any plugin linters registered via entry points.
275
+ runs built-in linters and any plugin linters registered via entry points.
284
276
 
285
- Returns a list of lint messages (errors and warnings).
277
+ returns a list of lint messages (errors and warnings).
286
278
  """
287
279
  messages: list[LintMessage] = []
288
280
 
289
- # Load plugin linters
290
281
  conjuring_linters = load_linters("conjurings")
291
282
  invocation_linters = load_linters("invocations")
292
283
 
293
- # Lint conjurings
294
284
  messages.extend(
295
285
  lint_collection(repo_root, "conjurings", lint_template, conjuring_linters)
296
286
  )
297
-
298
- # Lint invocations
299
287
  messages.extend(
300
288
  lint_collection(repo_root, "invocations", lint_invocation, invocation_linters)
301
289
  )
@@ -1,4 +1,4 @@
1
- """Render system and user prompts from conjurings and invocations."""
1
+ """render system and user prompts from conjurings and invocations."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -15,14 +15,15 @@ from pathlib import Path
15
15
  _VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
16
16
  _VALID_SNIPPET_PATH = re.compile(r"^[a-zA-Z0-9_.-]+(/[a-zA-Z0-9_.-]+)+$")
17
17
  _SNIPPET_INCLUDE = re.compile(r"{{>\s*snippet:([^}]+?)\s*}}")
18
+ _MAX_INCLUDE_DEPTH = 50
18
19
 
19
20
 
20
21
  class NotFoundError(Exception):
21
- """Raised when a conjuring, invocation, or snippet is not found."""
22
+ """raised when a conjuring, invocation, or snippet is not found."""
22
23
 
23
24
 
24
25
  def _safe_read_text(path: Path, label: str) -> str:
25
- """Read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
26
+ """read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
26
27
  try:
27
28
  return path.read_text(encoding="utf-8")
28
29
  except PermissionError:
@@ -32,7 +33,7 @@ def _safe_read_text(path: Path, label: str) -> str:
32
33
 
33
34
 
34
35
  def load_text(repo_root: Path, kind: str, name: str) -> str:
35
- """Load a conjuring or invocation; local overrides in .familiar override package data."""
36
+ """load a conjuring or invocation; local overrides in .familiar override package data."""
36
37
  if not _VALID_NAME.match(name):
37
38
  raise NotFoundError(f"invalid {kind.rstrip('s')} name: {name}")
38
39
  override = repo_root / ".familiar" / kind / f"{name}.md"
@@ -48,7 +49,7 @@ def load_text(repo_root: Path, kind: str, name: str) -> str:
48
49
 
49
50
 
50
51
  def load_snippet(repo_root: Path, path: str) -> str:
51
- """Load a snippet by path; local overrides in .familiar/snippets/ win."""
52
+ """load a snippet by path; local overrides in .familiar/snippets/ win."""
52
53
  path = path.strip()
53
54
  if ".." in path.split("/"):
54
55
  raise NotFoundError(f"invalid snippet path: {path}")
@@ -70,16 +71,33 @@ def load_snippet(repo_root: Path, path: str) -> str:
70
71
 
71
72
 
72
73
  def resolve_includes(repo_root: Path, text: str) -> str:
73
- """Resolve {{> snippet:path}} includes by replacing them with snippet content."""
74
+ """resolve {{> snippet:path}} includes, expanding nested includes recursively.
74
75
 
75
- def repl(m: re.Match[str]) -> str:
76
- return load_snippet(repo_root, m.group(1))
76
+ a snippet may include other snippets. include cycles (self-includes or
77
+ a -> b -> a) raise :class:`NotFoundError` naming the chain; a max-depth
78
+ backstop guards against pathologically deep nesting.
79
+ """
80
+
81
+ def expand(text: str, chain: list[str]) -> str:
82
+ def repl(m: re.Match[str]) -> str:
83
+ path = m.group(1).strip()
84
+ if path in chain:
85
+ trail = " -> ".join([*chain, path])
86
+ raise NotFoundError(f"snippet include cycle: {trail}")
87
+ if len(chain) >= _MAX_INCLUDE_DEPTH:
88
+ trail = " -> ".join([*chain, path])
89
+ raise NotFoundError(
90
+ f"snippet include depth exceeded ({_MAX_INCLUDE_DEPTH}): {trail}"
91
+ )
92
+ return expand(load_snippet(repo_root, path), [*chain, path])
93
+
94
+ return _SNIPPET_INCLUDE.sub(repl, text)
77
95
 
78
- return _SNIPPET_INCLUDE.sub(repl, text)
96
+ return expand(text, [])
79
97
 
80
98
 
81
99
  def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
82
- """Substitute $1, $2, ... $ARGUMENTS and {{key}} placeholders in a single pass."""
100
+ """substitute $1, $2, ... $ARGUMENTS and {{key}} placeholders in a single pass."""
83
101
  missing: list[str] = []
84
102
 
85
103
  def repl(m: re.Match[str]) -> str:
@@ -96,13 +114,16 @@ def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
96
114
  return ""
97
115
 
98
116
  if named_ident:
99
- return kv.get(named_ident, m.group(0))
117
+ if named_ident in kv:
118
+ return kv[named_ident]
119
+ missing.append(m.group(0))
120
+ return m.group(0)
100
121
 
101
122
  return m.group(0)
102
123
 
103
- # Combined regex for both types of placeholders:
104
- # Group 1: \$(ARGUMENTS|\d+)
105
- # Group 2: {{(\w+)}}
124
+ # combined regex for both types of placeholders:
125
+ # group 1: \$(ARGUMENTS|\d+)
126
+ # group 2: {{(\w+)}}
106
127
  pattern = re.compile(r"\$(ARGUMENTS|\d+)|{{(\w+)}}")
107
128
  text = pattern.sub(repl, text)
108
129
 
@@ -112,7 +133,7 @@ def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
112
133
 
113
134
 
114
135
  def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, str]]:
115
- """Recursively walk a Traversable, returning (relative_path, first_line) pairs."""
136
+ """recursively walk a Traversable, returning (relative_path, first_line) pairs."""
116
137
  items: list[tuple[str, str]] = []
117
138
  try:
118
139
  for item in sorted(root.iterdir(), key=lambda x: x.name):
@@ -139,23 +160,22 @@ def _list_resources(
139
160
  recursive: bool = False,
140
161
  suffix: str = "",
141
162
  ) -> list[tuple[str, str, bool]]:
142
- """List resources from package data and local overrides.
163
+ """list resources from package data and local overrides.
143
164
 
144
- Items from local ``.familiar/{local_subdir}/`` override package builtins.
165
+ items from local ``.familiar/{local_subdir}/`` override package builtins.
145
166
 
146
167
  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.
168
+ repo_root: repository root path.
169
+ pkg: package name to scan for builtins.
170
+ local_subdir: subdirectory name under ``.familiar/`` for local overrides.
171
+ recursive: if True, scan recursively (for nested resources like snippets).
172
+ suffix: file suffix filter (e.g. ``".md"``). empty string matches all files.
152
173
 
153
174
  Returns:
154
- Sorted list of ``(key, first_line, is_local)`` tuples.
175
+ sorted list of ``(key, first_line, is_local)`` tuples.
155
176
  """
156
177
  items: dict[str, tuple[str, bool]] = {}
157
178
 
158
- # built-in resources from package data
159
179
  try:
160
180
  pkg_root = resources.files(pkg)
161
181
  if recursive:
@@ -177,7 +197,6 @@ def _list_resources(
177
197
  except (FileNotFoundError, TypeError, ModuleNotFoundError):
178
198
  pass
179
199
 
180
- # local overrides
181
200
  local_dir = repo_root / ".familiar" / local_subdir
182
201
  if local_dir.is_dir():
183
202
  files = (
@@ -201,9 +220,9 @@ def _list_resources(
201
220
 
202
221
 
203
222
  def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
204
- """List available snippets.
223
+ """list available snippets.
205
224
 
206
- Returns list of (path, first_line, is_local) tuples, sorted by path.
225
+ returns list of (path, first_line, is_local) tuples, sorted by path.
207
226
  """
208
227
  return _list_resources(
209
228
  repo_root, "familiar.data.snippets", "snippets", recursive=True
@@ -211,15 +230,15 @@ def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
211
230
 
212
231
 
213
232
  def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
214
- """List available conjurings or invocations.
233
+ """list available conjurings or invocations.
215
234
 
216
- Returns list of (name, first_line, is_local) tuples, sorted by name.
235
+ returns list of (name, first_line, is_local) tuples, sorted by name.
217
236
  """
218
237
  return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
219
238
 
220
239
 
221
240
  def compose_system(repo_root: Path, conjurings: list[str]) -> str:
222
- """Compose system instructions from core + selected conjurings."""
241
+ """compose system instructions from core + selected conjurings."""
223
242
  core = load_text(repo_root, "conjurings", "core").strip()
224
243
  parts: list[str] = [core]
225
244
  for name in conjurings:
@@ -230,7 +249,7 @@ def compose_system(repo_root: Path, conjurings: list[str]) -> str:
230
249
  def render_invocation(
231
250
  repo_root: Path, invocation: str, args: list[str], kv: dict[str, str]
232
251
  ) -> str:
233
- """Render an invocation with snippet inclusion and argument substitution."""
252
+ """render an invocation with snippet inclusion and argument substitution."""
234
253
  inv = load_text(repo_root, "invocations", invocation).strip()
235
254
  inv = resolve_includes(repo_root, inv)
236
255
  return substitute(inv, args, kv)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.1
3
+ Version: 0.5.2
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
@@ -321,7 +321,6 @@ class TestCmdInvoke:
321
321
  )
322
322
  result = cmd_invoke(args)
323
323
  assert result == 0
324
- # check that the prompt is the rendered invocation
325
324
  call_args = mock_run.call_args
326
325
  prompt = call_args[0][2]
327
326
  assert "explain" in prompt.lower()
@@ -344,7 +343,6 @@ class TestCmdInvoke:
344
343
  cmd_invoke(args)
345
344
 
346
345
  def test_kv_args_passed(self, tmp_path):
347
- # create custom invocation that uses kv
348
346
  inv_dir = tmp_path / ".familiar" / "invocations"
349
347
  inv_dir.mkdir(parents=True)
350
348
  (inv_dir / "custom.md").write_text("value is {{mykey}}")
@@ -410,7 +408,6 @@ class TestCmdInvoke:
410
408
  ) as mock_wt:
411
409
  with patch("familiar.cli.run_agent", return_value=0) as mock_run:
412
410
  with patch("shutil.copy2") as mock_copy:
413
- # Create instruction file to be copied
414
411
  (tmp_path / "CLAUDE.md").write_text("instr")
415
412
 
416
413
  args = argparse.Namespace(
@@ -429,9 +426,7 @@ class TestCmdInvoke:
429
426
  result = cmd_invoke(args)
430
427
  assert result == 0
431
428
  mock_wt.assert_called_once()
432
- # Check that run_agent was called with the worktree path
433
429
  assert mock_run.call_args[0][0] == tmp_path / "wt"
434
- # Check that instruction file was copied
435
430
  mock_copy.assert_called_once()
436
431
 
437
432
  def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
@@ -571,7 +566,6 @@ class TestCmdList:
571
566
  )
572
567
  cmd_list(args)
573
568
  captured = capsys.readouterr()
574
- # verbose mode includes first line after colon
575
569
  assert ":" in captured.out
576
570
 
577
571
  def test_list_local_marked(self, tmp_path, capsys):
@@ -223,7 +223,6 @@ class TestListIntegration:
223
223
  text=True,
224
224
  )
225
225
  assert result.returncode == 0
226
- # verbose includes description after colon
227
226
  assert ":" in result.stdout
228
227
 
229
228
  def test_list_with_local_override(self, tmp_path):
@@ -267,7 +266,6 @@ class TestSnippetIntegration:
267
266
  from familiar.render import render_invocation
268
267
 
269
268
  result = render_invocation(tmp_path, "bootstrap-python", ["myapp", "cli"], {})
270
- # The snippet content should be inlined
271
269
  assert "[project]" in result
272
270
  assert "pyproject.toml" not in result or "snippet:" not in result
273
271
 
@@ -276,7 +274,6 @@ class TestSnippetIntegration:
276
274
  from familiar.render import render_invocation
277
275
 
278
276
  result = render_invocation(tmp_path, "add-ci", ["github", "python"], {})
279
- # Should contain inlined workflow, not the snippet directive
280
277
  assert "{{> snippet:" not in result
281
278
  assert "actions/checkout" in result
282
279
 
@@ -177,12 +177,38 @@ output
177
177
  placeholder_warnings = [m for m in messages if "{{myarg}}" in m.message]
178
178
  assert placeholder_warnings == []
179
179
 
180
+ def test_placeholder_substring_in_other_word_warns(self):
181
+ content = """task: process {{name}}
182
+
183
+ inputs
184
+ - filename (required): the file to process
185
+
186
+ output
187
+ - results
188
+ """
189
+ messages = lint_invocation(content, "test.md")
190
+ placeholder_warnings = [m for m in messages if "{{name}}" in m.message]
191
+ assert len(placeholder_warnings) == 1
192
+ assert placeholder_warnings[0].level == "warning"
193
+
194
+ def test_placeholder_tag_in_inputs_section_no_warning(self):
195
+ content = """task: implement {{spec}}
196
+
197
+ inputs
198
+ - {{spec}} (required): feature specification
199
+
200
+ output
201
+ - results
202
+ """
203
+ messages = lint_invocation(content, "test.md")
204
+ placeholder_warnings = [m for m in messages if "{{spec}}" in m.message]
205
+ assert placeholder_warnings == []
206
+
180
207
 
181
208
  class TestLintAll:
182
209
  """tests for linting all conjurings and invocations."""
183
210
 
184
211
  def test_lint_all_builtins(self, tmp_path):
185
- # should lint all built-ins without errors
186
212
  messages = lint_all(tmp_path)
187
213
  errors = [m for m in messages if m.level == "error"]
188
214
  assert errors == [], f"Unexpected errors: {errors}"
@@ -255,13 +281,12 @@ class TestLinterPlugins:
255
281
  mock_ep.name = "test"
256
282
  mock_ep.load.return_value = mock_linter
257
283
 
258
- # Create a local template to lint
259
284
  templates = tmp_path / ".familiar" / "conjurings"
260
285
  templates.mkdir(parents=True)
261
286
  (templates / "mytemplate.md").write_text("# test")
262
287
 
263
288
  with patch("familiar._plugins.entry_points") as mock_entry_points:
264
- # Return our mock for templates, empty for invocations
289
+ # return the mock for conjurings, empty for invocations
265
290
  def ep_side_effect(group):
266
291
  if group == "familiar.linters.conjurings":
267
292
  return [mock_ep]
@@ -270,7 +295,6 @@ class TestLinterPlugins:
270
295
  mock_entry_points.side_effect = ep_side_effect
271
296
  lint_all(tmp_path)
272
297
 
273
- # Should have been called for the local template
274
298
  local_calls = [c for c in calls if "mytemplate" in c[1]]
275
299
  assert len(local_calls) == 1
276
300
 
@@ -284,7 +308,6 @@ class TestLinterPlugins:
284
308
  mock_ep.name = "bad"
285
309
  mock_ep.load.return_value = bad_linter
286
310
 
287
- # Create a local template
288
311
  templates = tmp_path / ".familiar" / "conjurings"
289
312
  templates.mkdir(parents=True)
290
313
  (templates / "test.md").write_text("# test")
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import pytest
6
6
 
7
7
  from familiar.render import (
8
+ _MAX_INCLUDE_DEPTH,
8
9
  NotFoundError,
9
10
  compose_system,
10
11
  list_items,
@@ -62,6 +63,27 @@ class TestSubstitute:
62
63
  result = substitute(text, ["first", "second"], {})
63
64
  assert result == "second before first"
64
65
 
66
+ def test_missing_named_warns(self, capsys):
67
+ text = "spec: {{spec}}"
68
+ result = substitute(text, [], {})
69
+ assert result == "spec: {{spec}}"
70
+ captured = capsys.readouterr()
71
+ assert "warning: missing arguments: {{spec}}" in captured.err
72
+
73
+ def test_present_named_no_warning(self, capsys):
74
+ text = "spec: {{spec}}"
75
+ result = substitute(text, [], {"spec": "caching"})
76
+ assert result == "spec: caching"
77
+ captured = capsys.readouterr()
78
+ assert captured.err == ""
79
+
80
+ def test_missing_mixed_reports_both(self, capsys):
81
+ text = "$1 and {{spec}} and $2"
82
+ result = substitute(text, ["only_one"], {})
83
+ assert result == "only_one and {{spec}} and "
84
+ captured = capsys.readouterr()
85
+ assert "warning: missing arguments: {{spec}}, $2" in captured.err
86
+
65
87
 
66
88
  class TestLoadText:
67
89
  """tests for loading conjurings and invocations."""
@@ -129,7 +151,6 @@ class TestComposeSystem:
129
151
  assert "python" in system.lower()
130
152
 
131
153
  def test_compose_order(self, tmp_path):
132
- # create local overrides to control content
133
154
  templates = tmp_path / ".familiar" / "conjurings"
134
155
  templates.mkdir(parents=True)
135
156
  (templates / "core.md").write_text("CORE")
@@ -137,7 +158,6 @@ class TestComposeSystem:
137
158
  (templates / "second.md").write_text("SECOND")
138
159
 
139
160
  system = compose_system(tmp_path, ["first", "second"])
140
- # verify order: core, then conjurings in order
141
161
  assert system == "CORE\n\nFIRST\n\nSECOND"
142
162
 
143
163
  def test_compose_core_only(self, tmp_path):
@@ -417,6 +437,63 @@ class TestResolveIncludes:
417
437
  result = resolve_includes(tmp_path, text)
418
438
  assert result == "content"
419
439
 
440
+ def test_nested_include_resolves(self, tmp_path):
441
+ """a snippet that includes another snippet is expanded fully."""
442
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
443
+ snippet_dir.mkdir(parents=True)
444
+ (snippet_dir / "outer.txt").write_text("outer[{{> snippet:test/inner.txt}}]")
445
+ (snippet_dir / "inner.txt").write_text("INNER")
446
+
447
+ result = resolve_includes(tmp_path, "{{> snippet:test/outer.txt}}")
448
+ assert result == "outer[INNER]"
449
+ assert "{{> snippet" not in result
450
+
451
+ def test_deeply_nested_include_resolves(self, tmp_path):
452
+ """nested includes resolve through several levels."""
453
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
454
+ snippet_dir.mkdir(parents=True)
455
+ (snippet_dir / "a.txt").write_text("a({{> snippet:test/b.txt}})")
456
+ (snippet_dir / "b.txt").write_text("b({{> snippet:test/c.txt}})")
457
+ (snippet_dir / "c.txt").write_text("c")
458
+
459
+ result = resolve_includes(tmp_path, "{{> snippet:test/a.txt}}")
460
+ assert result == "a(b(c))"
461
+
462
+ def test_self_include_raises_cycle(self, tmp_path):
463
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
464
+ snippet_dir.mkdir(parents=True)
465
+ (snippet_dir / "loop.txt").write_text("{{> snippet:test/loop.txt}}")
466
+
467
+ with pytest.raises(NotFoundError, match="snippet include cycle") as exc:
468
+ resolve_includes(tmp_path, "{{> snippet:test/loop.txt}}")
469
+ assert "test/loop.txt -> test/loop.txt" in str(exc.value)
470
+
471
+ def test_mutual_include_raises_cycle(self, tmp_path):
472
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
473
+ snippet_dir.mkdir(parents=True)
474
+ (snippet_dir / "a.txt").write_text("{{> snippet:test/b.txt}}")
475
+ (snippet_dir / "b.txt").write_text("{{> snippet:test/a.txt}}")
476
+
477
+ with pytest.raises(NotFoundError, match="snippet include cycle") as exc:
478
+ resolve_includes(tmp_path, "{{> snippet:test/a.txt}}")
479
+ assert "test/a.txt" in str(exc.value)
480
+ assert "test/b.txt" in str(exc.value)
481
+
482
+ def test_include_depth_backstop(self, tmp_path):
483
+ """an over-deep acyclic chain raises rather than recursing without bound."""
484
+ snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
485
+ snippet_dir.mkdir(parents=True)
486
+ chain_len = _MAX_INCLUDE_DEPTH + 2
487
+ for i in range(chain_len):
488
+ if i < chain_len - 1:
489
+ body = "{{> snippet:test/s" + str(i + 1) + ".txt}}"
490
+ else:
491
+ body = "end"
492
+ (snippet_dir / f"s{i}.txt").write_text(body)
493
+
494
+ with pytest.raises(NotFoundError, match="depth exceeded"):
495
+ resolve_includes(tmp_path, "{{> snippet:test/s0.txt}}")
496
+
420
497
 
421
498
  class TestListSnippets:
422
499
  """tests for listing snippets."""
File without changes
File without changes
File without changes