familiar-cli 0.5.2__tar.gz → 0.6.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.5.2 → familiar_cli-0.6.0}/PKG-INFO +14 -5
  2. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/pyproject.toml +2 -2
  3. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/readme.md +12 -3
  4. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/__init__.py +1 -1
  5. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/_plugins.py +4 -2
  6. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/agents.py +3 -2
  7. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/cli.py +7 -5
  8. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/lint.py +147 -53
  9. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/render.py +37 -20
  10. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/PKG-INFO +14 -5
  11. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/requires.txt +1 -1
  12. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_agents.py +5 -4
  13. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_cli.py +108 -89
  14. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_integration.py +20 -0
  15. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_lint.py +284 -2
  16. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_render.py +61 -0
  17. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/LICENSE +0 -0
  18. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/setup.cfg +0 -0
  19. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/core.md +0 -0
  20. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/data.md +0 -0
  21. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/docs.md +0 -0
  22. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/frontend.md +0 -0
  23. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/infra.md +0 -0
  24. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/python.md +0 -0
  25. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/rust.md +0 -0
  26. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/sec.md +0 -0
  27. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/__noop__.md +0 -0
  28. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-ci.md +0 -0
  29. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-tests.md +0 -0
  30. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/audit.md +0 -0
  31. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  32. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  33. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/code-review.md +0 -0
  34. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/explain.md +0 -0
  35. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/implement-feature.md +0 -0
  36. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/infra-change.md +0 -0
  37. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/performance.md +0 -0
  38. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/refactor.md +0 -0
  39. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/release.md +0 -0
  40. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/security-review.md +0 -0
  41. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  42. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  43. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  44. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  45. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  46. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  47. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  48. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  49. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/py.typed +0 -0
  50. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  51. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  52. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  53. {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Provides-Extra: dev
23
- Requires-Dist: ruff; extra == "dev"
23
+ Requires-Dist: ruff==0.16.1; extra == "dev"
24
24
  Requires-Dist: mypy; extra == "dev"
25
25
  Requires-Dist: pytest; extra == "dev"
26
26
  Requires-Dist: pytest-cov; extra == "dev"
@@ -46,18 +46,21 @@ pip install familiar-cli
46
46
  ## usage
47
47
 
48
48
  ```
49
- usage: familiar [-h] {conjure,invoke,list} ...
49
+ usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
50
50
 
51
51
  conjure and invoke familiars
52
52
 
53
53
  positional arguments:
54
- {conjure,invoke,list}
54
+ {conjure,invoke,list,lint}
55
55
  conjure compose system instructions for an agent
56
56
  invoke render an invocation and run the agent
57
- list list available conjurings or invocations
57
+ list list available conjurings and invocations
58
+ lint lint conjurings and invocations
58
59
 
59
60
  options:
60
61
  -h, --help show this help message and exit
62
+ --version show program's version number and exit
63
+ --debug show full traceback on error
61
64
  ```
62
65
 
63
66
  conjure conjurings to create system instructions for an agent:
@@ -98,6 +101,12 @@ list available conjurings and invocations:
98
101
  familiar list
99
102
  ```
100
103
 
104
+ lint conjurings and invocations, including local `.familiar/` overrides:
105
+
106
+ ```
107
+ familiar lint
108
+ ```
109
+
101
110
  ## customization
102
111
 
103
112
  add your own conjurings and invocations by creating files in `.familiar/` in your repo:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "familiar-cli"
3
- version = "0.5.2"
3
+ version = "0.6.0"
4
4
  description = "compose and invoke ai agent prompts from reusable templates"
5
5
  readme = "readme.md"
6
6
  license = "MIT"
@@ -36,7 +36,7 @@ claude = "familiar.agents:ClaudeAgent"
36
36
  package-dir = {"" = "src"}
37
37
 
38
38
  [project.optional-dependencies]
39
- dev = ["ruff", "mypy", "pytest", "pytest-cov", "bandit"]
39
+ dev = ["ruff==0.16.1", "mypy", "pytest", "pytest-cov", "bandit"]
40
40
 
41
41
  [tool.setuptools.package-data]
42
42
  "familiar.data.conjurings" = ["*.md"]
@@ -17,18 +17,21 @@ pip install familiar-cli
17
17
  ## usage
18
18
 
19
19
  ```
20
- usage: familiar [-h] {conjure,invoke,list} ...
20
+ usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
21
21
 
22
22
  conjure and invoke familiars
23
23
 
24
24
  positional arguments:
25
- {conjure,invoke,list}
25
+ {conjure,invoke,list,lint}
26
26
  conjure compose system instructions for an agent
27
27
  invoke render an invocation and run the agent
28
- list list available conjurings or invocations
28
+ list list available conjurings and invocations
29
+ lint lint conjurings and invocations
29
30
 
30
31
  options:
31
32
  -h, --help show this help message and exit
33
+ --version show program's version number and exit
34
+ --debug show full traceback on error
32
35
  ```
33
36
 
34
37
  conjure conjurings to create system instructions for an agent:
@@ -69,6 +72,12 @@ list available conjurings and invocations:
69
72
  familiar list
70
73
  ```
71
74
 
75
+ lint conjurings and invocations, including local `.familiar/` overrides:
76
+
77
+ ```
78
+ familiar lint
79
+ ```
80
+
72
81
  ## customization
73
82
 
74
83
  add your own conjurings and invocations by creating files in `.familiar/` in your repo:
@@ -2,6 +2,6 @@
2
2
 
3
3
  from importlib.metadata import version
4
4
 
5
- __all__ = ["agents", "render", "cli"]
5
+ __all__ = ["agents", "cli", "render"]
6
6
 
7
7
  __version__ = version("familiar-cli")
@@ -3,8 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import warnings
6
+ from collections.abc import Callable
6
7
  from importlib.metadata import entry_points
7
- from typing import Any, Callable
8
+ from typing import Any
8
9
 
9
10
 
10
11
  def load_plugins(
@@ -36,7 +37,8 @@ def load_plugins(
36
37
  )
37
38
  continue
38
39
  results.append(obj)
39
- except Exception as e:
40
+ # a broken third-party entry point must not take down the cli
41
+ except Exception as e: # noqa: BLE001
40
42
  warnings.warn(
41
43
  f"failed to load {label} '{ep.name}': {e}",
42
44
  stacklevel=2,
@@ -61,7 +61,7 @@ class CodexAgent(Agent):
61
61
  if auto:
62
62
  cmd.append("--full-auto")
63
63
  cmd.extend(["exec", "--skip-git-repo-check", "-C", str(repo_root), "-"])
64
- proc = subprocess.run(cmd, input=prompt, text=True)
64
+ proc = subprocess.run(cmd, input=prompt, text=True, check=False)
65
65
  return proc.returncode
66
66
  else:
67
67
  cmd = ["codex"]
@@ -108,7 +108,8 @@ def load_agents() -> dict[str, Agent]:
108
108
  try:
109
109
  instance = cls()
110
110
  agents[instance.name] = instance
111
- except Exception as e:
111
+ # a broken agent plugin must not take down the cli
112
+ except Exception as e: # noqa: BLE001
112
113
  warnings.warn(
113
114
  f"failed to load agent plugin '{cls.__name__}': {e}",
114
115
  stacklevel=2,
@@ -14,7 +14,7 @@ import warnings
14
14
  from importlib.metadata import version
15
15
  from pathlib import Path
16
16
 
17
- from .agents import Agent, get_agents, get_agent
17
+ from .agents import Agent, get_agent, get_agents
18
18
  from .lint import lint_all
19
19
  from .render import (
20
20
  NotFoundError,
@@ -46,7 +46,7 @@ def _agent_hint() -> str:
46
46
  return f"valid agents: {', '.join(get_agents().keys())}"
47
47
 
48
48
 
49
- def _resolve_agent(name: str) -> "Agent":
49
+ def _resolve_agent(name: str) -> Agent:
50
50
  """look up an agent by name, raising CliError on unknown names."""
51
51
  try:
52
52
  return get_agent(name)
@@ -94,6 +94,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
94
94
  str(worktree_path),
95
95
  ],
96
96
  capture_output=True,
97
+ check=False,
97
98
  )
98
99
  if result.returncode != 0:
99
100
  stderr = result.stderr.decode(errors="replace").strip()
@@ -119,8 +120,8 @@ def create_worktree(repo_root: Path) -> Path:
119
120
  )
120
121
 
121
122
  tmpdir = tempfile.mkdtemp(prefix="familiar-")
122
- # git worktree add requires the directory to NOT exist.
123
- # mkdtemp creates it. let's remove it and let git create it.
123
+ # git worktree add requires the target to not exist, so remove the
124
+ # mkdtemp-created directory and let git create it
124
125
  os.rmdir(tmpdir)
125
126
 
126
127
  try:
@@ -509,7 +510,8 @@ def main() -> None:
509
510
  if e.hint:
510
511
  print(f"hint: {e.hint}", file=sys.stderr)
511
512
  raise SystemExit(e.exit_code)
512
- except Exception as e:
513
+ # top-level handler: any unexpected error becomes an exit code, not a traceback
514
+ except Exception as e: # noqa: BLE001
513
515
  if args.debug:
514
516
  traceback.print_exc()
515
517
  print(f"error: {e}", file=sys.stderr)
@@ -3,13 +3,22 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import re
6
+ from collections.abc import Callable
6
7
  from dataclasses import dataclass
7
8
  from pathlib import Path
8
- from typing import Callable, Literal
9
+ from typing import Literal
9
10
 
10
11
  from ._plugins import load_plugins
11
-
12
- from .render import _SNIPPET_INCLUDE, NotFoundError, list_items, load_snippet, load_text
12
+ from .render import (
13
+ _SNIPPET_INCLUDE,
14
+ NotFoundError,
15
+ _expand_includes,
16
+ list_items,
17
+ list_snippets,
18
+ load_snippet,
19
+ load_text,
20
+ resolve_includes,
21
+ )
13
22
 
14
23
 
15
24
  @dataclass
@@ -41,7 +50,7 @@ _INPUTS_SECTION = re.compile(
41
50
  r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE | re.MULTILINE
42
51
  )
43
52
  _OUTPUT_SECTION = re.compile(
44
- r"^(##\s+)?(output|deliverables?):?\s*$", re.IGNORECASE | re.MULTILINE
53
+ r"^(##\s+)?(outputs?|deliverables?):?\s*$", re.IGNORECASE | re.MULTILINE
45
54
  )
46
55
 
47
56
 
@@ -79,6 +88,61 @@ def lint_template(content: str, name: str) -> list[LintMessage]:
79
88
  return messages
80
89
 
81
90
 
91
+ def _check_placeholder_docs(
92
+ positional: set[str], named: set[str], doc_content: str, name: str
93
+ ) -> list[LintMessage]:
94
+ """warn about placeholders not documented in ``doc_content``'s inputs section."""
95
+ messages: list[LintMessage] = []
96
+
97
+ # loose check: prefer the inputs section if it exists, else the whole file
98
+ inputs_match = _INPUTS_SECTION.search(doc_content)
99
+ if inputs_match:
100
+ start = inputs_match.end()
101
+ # look ahead for the next markdown heading or end of file
102
+ next_heading = re.search(r"^#", doc_content[start:], re.MULTILINE)
103
+ search_area = (
104
+ doc_content[start : start + next_heading.start()]
105
+ if next_heading
106
+ else doc_content[start:]
107
+ ).lower()
108
+ else:
109
+ search_area = doc_content.lower()
110
+
111
+ for placeholder in named:
112
+ tag = f"{{{{{placeholder.lower()}}}}}"
113
+ stripped = search_area.replace(tag, "")
114
+ # accept the placeholder as documented if its bare name appears as a
115
+ # standalone word (after stripping {{…}} syntax) OR if the {{…}} tag
116
+ # itself appears in the inputs section (the common "- {{name}}: …"
117
+ # documentation pattern).
118
+ bare_match = re.search(r"\b" + re.escape(placeholder.lower()) + r"\b", stripped)
119
+ if not bare_match and tag not in search_area:
120
+ messages.append(
121
+ LintMessage(
122
+ level="warning",
123
+ file=name,
124
+ line=None,
125
+ message=f"placeholder '{{{{{placeholder}}}}}' may not be documented in inputs",
126
+ )
127
+ )
128
+
129
+ for p in positional:
130
+ if p == "ARGUMENTS":
131
+ continue
132
+ pattern = rf"(\w+[:\s]+\${p}|\${p}\s+[`\w]|\${p}\s*\()"
133
+ if not re.search(pattern, search_area):
134
+ messages.append(
135
+ LintMessage(
136
+ level="warning",
137
+ file=name,
138
+ line=None,
139
+ message=f"placeholder '${p}' may not be documented in inputs",
140
+ )
141
+ )
142
+
143
+ return messages
144
+
145
+
82
146
  def lint_invocation(content: str, name: str) -> list[LintMessage]:
83
147
  """lint an invocation file.
84
148
 
@@ -135,52 +199,7 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
135
199
 
136
200
  positional = set(_POSITIONAL_PLACEHOLDER.findall(content))
137
201
  named = set(_NAMED_PLACEHOLDER.findall(content))
138
-
139
- # loose check: prefer the inputs section if it exists, else the whole file
140
- inputs_match = _INPUTS_SECTION.search(content)
141
- if inputs_match:
142
- start = inputs_match.end()
143
- # look ahead for the next markdown heading or end of file
144
- next_heading = re.search(r"^#", content[start:], re.MULTILINE)
145
- search_area = (
146
- content[start : start + next_heading.start()]
147
- if next_heading
148
- else content[start:]
149
- ).lower()
150
- else:
151
- search_area = content.lower()
152
-
153
- for placeholder in named:
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:
162
- messages.append(
163
- LintMessage(
164
- level="warning",
165
- file=name,
166
- line=None,
167
- message=f"placeholder '{{{{{placeholder}}}}}' may not be documented in inputs",
168
- )
169
- )
170
-
171
- for p in positional:
172
- if p == "ARGUMENTS":
173
- continue
174
- pattern = rf"(\w+[:\s]+\${p}|\${p}\s+[`\w]|\${p}\s*\()"
175
- if not re.search(pattern, search_area):
176
- messages.append(
177
- LintMessage(
178
- level="warning",
179
- file=name,
180
- line=None,
181
- message=f"placeholder '${p}' may not be documented in inputs",
182
- )
183
- )
202
+ messages.extend(_check_placeholder_docs(positional, named, content, name))
184
203
 
185
204
  return messages
186
205
 
@@ -208,13 +227,19 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
208
227
  def lint_snippet_references(
209
228
  repo_root: Path, content: str, name: str
210
229
  ) -> list[LintMessage]:
211
- """check that all snippet includes reference existing snippets."""
230
+ """check that snippet includes resolve, following them transitively.
231
+
232
+ each top-level ``{{> snippet:path}}`` directive is validated along with
233
+ every snippet it pulls in, mirroring how the renderer expands includes at
234
+ conjure/invoke time. a broken reference, an include cycle, or an over-deep
235
+ chain is reported against the line of the top-level directive.
236
+ """
212
237
  messages: list[LintMessage] = []
213
238
  for i, line in enumerate(content.split("\n"), 1):
214
239
  for m in _SNIPPET_INCLUDE.finditer(line):
215
240
  snippet_path = m.group(1).strip()
216
241
  try:
217
- load_snippet(repo_root, snippet_path)
242
+ body = load_snippet(repo_root, snippet_path)
218
243
  except NotFoundError:
219
244
  messages.append(
220
245
  LintMessage(
@@ -224,9 +249,74 @@ def lint_snippet_references(
224
249
  message=f"snippet not found: {snippet_path}",
225
250
  )
226
251
  )
252
+ continue
253
+ try:
254
+ _expand_includes(repo_root, body, [snippet_path])
255
+ except NotFoundError as e:
256
+ messages.append(
257
+ LintMessage(
258
+ level="error",
259
+ file=name,
260
+ line=i,
261
+ message=str(e),
262
+ )
263
+ )
227
264
  return messages
228
265
 
229
266
 
267
+ def lint_snippet_collection(repo_root: Path) -> list[LintMessage]:
268
+ """lint every snippet body for broken, cyclic, or over-deep includes.
269
+
270
+ a snippet may include other snippets; a bad snippet->snippet include stays
271
+ invisible until something transitively pulls it in. each snippet is checked
272
+ against its own path.
273
+ """
274
+ messages: list[LintMessage] = []
275
+ for path, _, is_local in list_snippets(repo_root):
276
+ prefix = (
277
+ f".familiar/snippets/{path}" if is_local else f"(builtin) snippets/{path}"
278
+ )
279
+ try:
280
+ content = load_snippet(repo_root, path)
281
+ except NotFoundError as e:
282
+ messages.append(
283
+ LintMessage(
284
+ level="error",
285
+ file=prefix,
286
+ line=None,
287
+ message=f"failed to load: {e}",
288
+ )
289
+ )
290
+ continue
291
+ messages.extend(lint_snippet_references(repo_root, content, prefix))
292
+ return messages
293
+
294
+
295
+ def lint_snippet_placeholders(
296
+ repo_root: Path, content: str, name: str
297
+ ) -> list[LintMessage]:
298
+ """check placeholders that included snippets contribute to an invocation.
299
+
300
+ the renderer expands includes before substituting, so a ``$1``/``{{key}}``
301
+ inside an included snippet is live at invoke time but invisible to the
302
+ raw-text check in :func:`lint_invocation`. only the snippet-contributed
303
+ delta is checked here; broken or cyclic includes are left to
304
+ :func:`lint_snippet_references`.
305
+ """
306
+ try:
307
+ expanded = resolve_includes(repo_root, content)
308
+ except NotFoundError:
309
+ return []
310
+
311
+ positional = set(_POSITIONAL_PLACEHOLDER.findall(expanded)) - set(
312
+ _POSITIONAL_PLACEHOLDER.findall(content)
313
+ )
314
+ named = set(_NAMED_PLACEHOLDER.findall(expanded)) - set(
315
+ _NAMED_PLACEHOLDER.findall(content)
316
+ )
317
+ return _check_placeholder_docs(positional, named, content, name)
318
+
319
+
230
320
  def lint_collection(
231
321
  repo_root: Path,
232
322
  kind: Literal["conjurings", "invocations"],
@@ -245,10 +335,13 @@ def lint_collection(
245
335
  )
246
336
  messages.extend(builtin_linter(content, prefix))
247
337
  messages.extend(lint_snippet_references(repo_root, content, prefix))
338
+ if kind == "invocations":
339
+ messages.extend(lint_snippet_placeholders(repo_root, content, prefix))
248
340
  for linter in plugin_linters:
249
341
  try:
250
342
  messages.extend(linter(content, prefix))
251
- except Exception as e:
343
+ # a broken plugin linter is reported, not fatal to the run
344
+ except Exception as e: # noqa: BLE001
252
345
  messages.append(
253
346
  LintMessage(
254
347
  level="error",
@@ -287,5 +380,6 @@ def lint_all(repo_root: Path) -> list[LintMessage]:
287
380
  messages.extend(
288
381
  lint_collection(repo_root, "invocations", lint_invocation, invocation_linters)
289
382
  )
383
+ messages.extend(lint_snippet_collection(repo_root))
290
384
 
291
385
  return messages
@@ -70,6 +70,32 @@ def load_snippet(repo_root: Path, path: str) -> str:
70
70
  raise NotFoundError(f"unknown snippet: {path}")
71
71
 
72
72
 
73
+ def _expand_includes(repo_root: Path, text: str, chain: list[str]) -> str:
74
+ """recursively expand {{> snippet:path}} includes in ``text``.
75
+
76
+ ``chain`` is the stack of ancestor snippet paths currently being expanded,
77
+ used to detect include cycles and cap nesting depth. shared by
78
+ :func:`resolve_includes` and the linter so both agree on exactly what
79
+ resolves, cycles, or exceeds depth.
80
+ """
81
+
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_includes(
93
+ repo_root, load_snippet(repo_root, path), [*chain, path]
94
+ )
95
+
96
+ return _SNIPPET_INCLUDE.sub(repl, text)
97
+
98
+
73
99
  def resolve_includes(repo_root: Path, text: str) -> str:
74
100
  """resolve {{> snippet:path}} includes, expanding nested includes recursively.
75
101
 
@@ -77,23 +103,7 @@ def resolve_includes(repo_root: Path, text: str) -> str:
77
103
  a -> b -> a) raise :class:`NotFoundError` naming the chain; a max-depth
78
104
  backstop guards against pathologically deep nesting.
79
105
  """
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)
95
-
96
- return expand(text, [])
106
+ return _expand_includes(repo_root, text, [])
97
107
 
98
108
 
99
109
  def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
@@ -238,11 +248,18 @@ def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
238
248
 
239
249
 
240
250
  def compose_system(repo_root: Path, conjurings: list[str]) -> str:
241
- """compose system instructions from core + selected conjurings."""
251
+ """compose system instructions from core + selected conjurings.
252
+
253
+ snippet includes ({{> snippet:path}}) in the core text and in each
254
+ conjuring are expanded, mirroring :func:`render_invocation`. conjurings
255
+ receive no placeholder substitution, so any $N/{{key}} in an included
256
+ snippet is left verbatim.
257
+ """
242
258
  core = load_text(repo_root, "conjurings", "core").strip()
243
- parts: list[str] = [core]
259
+ parts: list[str] = [resolve_includes(repo_root, core)]
244
260
  for name in conjurings:
245
- parts.append(load_text(repo_root, "conjurings", name).strip())
261
+ text = load_text(repo_root, "conjurings", name).strip()
262
+ parts.append(resolve_includes(repo_root, text))
246
263
  return "\n\n".join(parts)
247
264
 
248
265
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: familiar-cli
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
20
20
  Description-Content-Type: text/markdown
21
21
  License-File: LICENSE
22
22
  Provides-Extra: dev
23
- Requires-Dist: ruff; extra == "dev"
23
+ Requires-Dist: ruff==0.16.1; extra == "dev"
24
24
  Requires-Dist: mypy; extra == "dev"
25
25
  Requires-Dist: pytest; extra == "dev"
26
26
  Requires-Dist: pytest-cov; extra == "dev"
@@ -46,18 +46,21 @@ pip install familiar-cli
46
46
  ## usage
47
47
 
48
48
  ```
49
- usage: familiar [-h] {conjure,invoke,list} ...
49
+ usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
50
50
 
51
51
  conjure and invoke familiars
52
52
 
53
53
  positional arguments:
54
- {conjure,invoke,list}
54
+ {conjure,invoke,list,lint}
55
55
  conjure compose system instructions for an agent
56
56
  invoke render an invocation and run the agent
57
- list list available conjurings or invocations
57
+ list list available conjurings and invocations
58
+ lint lint conjurings and invocations
58
59
 
59
60
  options:
60
61
  -h, --help show this help message and exit
62
+ --version show program's version number and exit
63
+ --debug show full traceback on error
61
64
  ```
62
65
 
63
66
  conjure conjurings to create system instructions for an agent:
@@ -98,6 +101,12 @@ list available conjurings and invocations:
98
101
  familiar list
99
102
  ```
100
103
 
104
+ lint conjurings and invocations, including local `.familiar/` overrides:
105
+
106
+ ```
107
+ familiar lint
108
+ ```
109
+
101
110
  ## customization
102
111
 
103
112
  add your own conjurings and invocations by creating files in `.familiar/` in your repo:
@@ -1,6 +1,6 @@
1
1
 
2
2
  [dev]
3
- ruff
3
+ ruff==0.16.1
4
4
  mypy
5
5
  pytest
6
6
  pytest-cov
@@ -2,17 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from unittest.mock import MagicMock, patch
6
+
5
7
  import pytest
6
- from unittest.mock import patch, MagicMock
7
8
 
8
9
  import familiar.agents as agents_module
9
10
  from familiar.agents import (
10
11
  Agent,
11
- CodexAgent,
12
12
  ClaudeAgent,
13
- load_agents,
14
- get_agents,
13
+ CodexAgent,
15
14
  get_agent,
15
+ get_agents,
16
+ load_agents,
16
17
  )
17
18
 
18
19