familiar-cli 0.5.0__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.0/src/familiar_cli.egg-info → familiar_cli-0.5.2}/PKG-INFO +1 -1
  2. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/pyproject.toml +1 -1
  3. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/_plugins.py +7 -7
  4. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/agents.py +13 -13
  5. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/cli.py +47 -38
  6. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/lint.py +32 -44
  7. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/render.py +73 -35
  8. {familiar_cli-0.5.0 → familiar_cli-0.5.2/src/familiar_cli.egg-info}/PKG-INFO +1 -1
  9. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_cli.py +91 -6
  10. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_integration.py +0 -3
  11. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_lint.py +72 -5
  12. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/tests/test_render.py +154 -2
  13. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/LICENSE +0 -0
  14. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/readme.md +0 -0
  15. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/setup.cfg +0 -0
  16. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/__init__.py +0 -0
  17. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/core.md +0 -0
  18. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/data.md +0 -0
  19. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/docs.md +0 -0
  20. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/frontend.md +0 -0
  21. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/infra.md +0 -0
  22. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/python.md +0 -0
  23. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/rust.md +0 -0
  24. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/conjurings/sec.md +0 -0
  25. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/__noop__.md +0 -0
  26. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-ci.md +0 -0
  27. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/add-tests.md +0 -0
  28. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/audit.md +0 -0
  29. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-python.md +0 -0
  30. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
  31. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/code-review.md +0 -0
  32. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/explain.md +0 -0
  33. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/implement-feature.md +0 -0
  34. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/infra-change.md +0 -0
  35. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/performance.md +0 -0
  36. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/refactor.md +0 -0
  37. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/release.md +0 -0
  38. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/invocations/security-review.md +0 -0
  39. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/github-ci.yml +0 -0
  40. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
  41. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/github-ci.yml +0 -0
  42. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
  43. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/python/pyproject.toml +0 -0
  44. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
  45. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
  46. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
  47. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar/py.typed +0 -0
  48. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
  49. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
  50. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/entry_points.txt +0 -0
  51. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/requires.txt +0 -0
  52. {familiar_cli-0.5.0 → familiar_cli-0.5.2}/src/familiar_cli.egg-info/top_level.txt +0 -0
  53. {familiar_cli-0.5.0 → 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.0
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.0"
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
 
@@ -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 get_agents, get_agent
17
+ from .agents import Agent, get_agents, get_agent
18
18
  from .lint import lint_all
19
19
  from .render import (
20
20
  NotFoundError,
@@ -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,14 +42,35 @@ 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
 
49
+ def _resolve_agent(name: str) -> "Agent":
50
+ """look up an agent by name, raising CliError on unknown names."""
51
+ try:
52
+ return get_agent(name)
53
+ except KeyError as e:
54
+ raise CliError(str(e), hint=_agent_hint())
55
+
56
+
57
+ def _write_file(path: Path, content: str, label: str = "") -> None:
58
+ """write content to path, creating parent dirs. wraps OSError as CliError."""
59
+ prefix = f"{label} " if label else ""
60
+ try:
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ path.write_text(content, encoding="utf-8")
63
+ except OSError as e:
64
+ raise CliError(
65
+ f"cannot write {prefix}{path}: {e}",
66
+ hint="check file permissions and disk space",
67
+ )
68
+
69
+
50
70
  def find_repo_root(start: Path) -> Path:
51
- """Find the repository root by looking for .git directory.
71
+ """find the repository root by looking for .git directory.
52
72
 
53
- Walks up from start directory. Falls back to start directory itself
73
+ walks up from start directory. falls back to start directory itself
54
74
  if no .git is found, allowing use outside of git repositories.
55
75
  """
56
76
  cur = start.resolve()
@@ -61,9 +81,9 @@ def find_repo_root(start: Path) -> Path:
61
81
 
62
82
 
63
83
  def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
64
- """Best-effort removal of a git worktree."""
84
+ """best-effort removal of a git worktree."""
65
85
  try:
66
- subprocess.run(
86
+ result = subprocess.run(
67
87
  [
68
88
  "git",
69
89
  "-C",
@@ -75,13 +95,18 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
75
95
  ],
76
96
  capture_output=True,
77
97
  )
98
+ if result.returncode != 0:
99
+ stderr = result.stderr.decode(errors="replace").strip()
100
+ warnings.warn(
101
+ f"failed to remove worktree {worktree_path}: {stderr}",
102
+ stacklevel=2,
103
+ )
78
104
  except FileNotFoundError:
79
105
  pass
80
106
 
81
107
 
82
108
  def create_worktree(repo_root: Path) -> Path:
83
- """Create a temporary git worktree for the repository."""
84
- # Ensure it's a git repo
109
+ """create a temporary git worktree for the repository."""
85
110
  try:
86
111
  subprocess.run(
87
112
  ["git", "-C", str(repo_root), "rev-parse", "--is-inside-work-tree"],
@@ -95,7 +120,7 @@ def create_worktree(repo_root: Path) -> Path:
95
120
 
96
121
  tmpdir = tempfile.mkdtemp(prefix="familiar-")
97
122
  # git worktree add requires the directory to NOT exist.
98
- # 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.
99
124
  os.rmdir(tmpdir)
100
125
 
101
126
  try:
@@ -120,33 +145,27 @@ def create_worktree(repo_root: Path) -> Path:
120
145
 
121
146
 
122
147
  def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
123
- try:
124
- agent = get_agent(agent_name)
125
- except KeyError as e:
126
- raise CliError(str(e), hint=_agent_hint())
127
- (repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
148
+ agent = _resolve_agent(agent_name)
149
+ dest = repo_root / agent.output_file
150
+ _write_file(dest, system.strip() + "\n")
128
151
 
129
152
 
130
153
  def write_skill(
131
154
  repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
132
155
  ) -> Path:
133
- """Write a reusable skill file for supported agents."""
156
+ """write a reusable skill file for supported agents."""
134
157
  if not _VALID_NAME.match(skill_name):
135
158
  raise CliError(
136
159
  f"invalid skill name: {skill_name}",
137
160
  hint="use lowercase letters, numbers, underscore, or hyphen",
138
161
  )
139
162
 
140
- try:
141
- agent = get_agent(agent_name)
142
- except KeyError as e:
143
- raise CliError(str(e), hint=_agent_hint())
163
+ agent = _resolve_agent(agent_name)
144
164
 
145
165
  if not agent.supports_skills():
146
166
  raise CliError(f"agent does not support skills: {agent_name}")
147
167
 
148
168
  skill_path = agent.skill_path(repo_root, skill_name)
149
- skill_path.parent.mkdir(parents=True, exist_ok=True)
150
169
  content = (
151
170
  f"# {skill_name}\n\n"
152
171
  "use this skill when this recurring task appears in this repository.\n\n"
@@ -156,7 +175,7 @@ def write_skill(
156
175
  "## instructions\n\n"
157
176
  f"{prompt.strip()}\n"
158
177
  )
159
- skill_path.write_text(content, encoding="utf-8")
178
+ _write_file(skill_path, content, label="skill")
160
179
  return skill_path
161
180
 
162
181
 
@@ -167,23 +186,19 @@ def write_subagent(
167
186
  subagent_name: str,
168
187
  system: str,
169
188
  ) -> Path:
170
- """Write a reusable subagent file for supported agents."""
189
+ """write a reusable subagent file for supported agents."""
171
190
  if not _VALID_NAME.match(subagent_name):
172
191
  raise CliError(
173
192
  f"invalid subagent name: {subagent_name}",
174
193
  hint="use lowercase letters, numbers, underscore, or hyphen",
175
194
  )
176
195
 
177
- try:
178
- agent = get_agent(agent_name)
179
- except KeyError as e:
180
- raise CliError(str(e), hint=_agent_hint())
196
+ agent = _resolve_agent(agent_name)
181
197
 
182
198
  if not agent.supports_subagents():
183
199
  raise CliError(f"agent does not support subagents: {agent_name}")
184
200
 
185
201
  subagent_path = agent.subagent_path(repo_root, subagent_name)
186
- subagent_path.parent.mkdir(parents=True, exist_ok=True)
187
202
  profile = ", ".join(conjurings)
188
203
  content = (
189
204
  f"# {subagent_name}\n\n"
@@ -194,7 +209,7 @@ def write_subagent(
194
209
  "## instructions\n\n"
195
210
  f"{system.strip()}\n"
196
211
  )
197
- subagent_path.write_text(content, encoding="utf-8")
212
+ _write_file(subagent_path, content, label="subagent")
198
213
  return subagent_path
199
214
 
200
215
 
@@ -214,10 +229,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
214
229
  def run_agent(
215
230
  repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
216
231
  ) -> int:
217
- try:
218
- agent = get_agent(agent_name)
219
- except KeyError as e:
220
- raise CliError(str(e), hint=_agent_hint())
232
+ agent = _resolve_agent(agent_name)
221
233
  try:
222
234
  return agent.run(repo_root, prompt, headless, auto=auto)
223
235
  except FileNotFoundError:
@@ -277,7 +289,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
277
289
  target_dir = create_worktree(repo_root)
278
290
  print(f"created worktree at {target_dir}")
279
291
 
280
- # Best-effort copy of instruction file into the worktree
292
+ # best-effort copy of instruction file into the worktree
281
293
  try:
282
294
  agent = get_agent(args.agent)
283
295
  instr_file = repo_root / agent.output_file
@@ -318,7 +330,6 @@ def cmd_list(args: argparse.Namespace) -> int:
318
330
  repo_root = find_repo_root(Path(args.into or os.getcwd()))
319
331
 
320
332
  if args.kind is None:
321
- # list all
322
333
  conjurings = list_items(repo_root, "conjurings")
323
334
  invocations = list_items(repo_root, "invocations")
324
335
  snippets = list_snippets(repo_root)
@@ -349,7 +360,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
349
360
 
350
361
  messages = lint_all(repo_root)
351
362
 
352
- # Filter by level if requested
353
363
  if args.errors_only:
354
364
  messages = [m for m in messages if m.level == "error"]
355
365
 
@@ -357,7 +367,6 @@ def cmd_lint(args: argparse.Namespace) -> int:
357
367
  print("all checks passed")
358
368
  return EXIT_SUCCESS
359
369
 
360
- # Group by level for output
361
370
  errors = [m for m in messages if m.level == "error"]
362
371
  warnings = [m for m in messages if m.level == "warning"]
363
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))
@@ -265,7 +257,7 @@ def lint_collection(
265
257
  message=f"plugin linter failed: {e}",
266
258
  )
267
259
  )
268
- except Exception as e:
260
+ except NotFoundError as e:
269
261
  messages.append(
270
262
  LintMessage(
271
263
  level="error",
@@ -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
  )