familiar-cli 0.4.0__tar.gz → 0.5.1__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.
- {familiar_cli-0.4.0/src/familiar_cli.egg-info → familiar_cli-0.5.1}/PKG-INFO +1 -1
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/pyproject.toml +1 -1
- familiar_cli-0.5.1/src/familiar/_plugins.py +44 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/agents.py +10 -13
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/cli.py +76 -29
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/lint.py +9 -24
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/render.py +80 -47
- {familiar_cli-0.4.0 → familiar_cli-0.5.1/src/familiar_cli.egg-info}/PKG-INFO +1 -1
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_agents.py +3 -3
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_cli.py +126 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_lint.py +48 -4
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_render.py +164 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/LICENSE +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/readme.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/setup.cfg +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/py.typed +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.1}/tests/test_integration.py +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Shared plugin loading utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from importlib.metadata import entry_points
|
|
7
|
+
from typing import Any, Callable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_plugins(
|
|
11
|
+
group: str,
|
|
12
|
+
validate: Callable[[Any], bool],
|
|
13
|
+
*,
|
|
14
|
+
label: str,
|
|
15
|
+
invalid_msg: str = "validation failed",
|
|
16
|
+
) -> list[Any]:
|
|
17
|
+
"""Load plugins from entry points with validation.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
group: Entry point group name.
|
|
21
|
+
validate: Predicate that returns True for valid loaded objects.
|
|
22
|
+
label: Human-readable label for warning messages (e.g. "linter plugin").
|
|
23
|
+
invalid_msg: Message when validation fails.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of loaded and validated plugin objects.
|
|
27
|
+
"""
|
|
28
|
+
results: list[Any] = []
|
|
29
|
+
for ep in entry_points(group=group):
|
|
30
|
+
try:
|
|
31
|
+
obj = ep.load()
|
|
32
|
+
if not validate(obj):
|
|
33
|
+
warnings.warn(
|
|
34
|
+
f"{label} '{ep.name}': {invalid_msg}",
|
|
35
|
+
stacklevel=2,
|
|
36
|
+
)
|
|
37
|
+
continue
|
|
38
|
+
results.append(obj)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
warnings.warn(
|
|
41
|
+
f"failed to load {label} '{ep.name}': {e}",
|
|
42
|
+
stacklevel=2,
|
|
43
|
+
)
|
|
44
|
+
return results
|
|
@@ -5,9 +5,10 @@ from __future__ import annotations
|
|
|
5
5
|
import subprocess
|
|
6
6
|
import warnings
|
|
7
7
|
from abc import ABC, abstractmethod
|
|
8
|
-
from importlib.metadata import entry_points
|
|
9
8
|
from pathlib import Path
|
|
10
9
|
|
|
10
|
+
from ._plugins import load_plugins
|
|
11
|
+
|
|
11
12
|
|
|
12
13
|
class Agent(ABC):
|
|
13
14
|
"""Base class for AI coding agents."""
|
|
@@ -96,26 +97,22 @@ def load_agents() -> dict[str, Agent]:
|
|
|
96
97
|
Dictionary mapping agent names to Agent instances.
|
|
97
98
|
Plugins that fail to load are skipped with a warning.
|
|
98
99
|
"""
|
|
100
|
+
plugins = load_plugins(
|
|
101
|
+
"familiar.agents",
|
|
102
|
+
lambda cls: isinstance(cls, type) and issubclass(cls, Agent),
|
|
103
|
+
label="agent plugin",
|
|
104
|
+
invalid_msg="not a valid Agent subclass",
|
|
105
|
+
)
|
|
99
106
|
agents: dict[str, Agent] = {}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
for ep in eps:
|
|
107
|
+
for cls in plugins:
|
|
103
108
|
try:
|
|
104
|
-
cls = ep.load()
|
|
105
|
-
if not (isinstance(cls, type) and issubclass(cls, Agent)):
|
|
106
|
-
warnings.warn(
|
|
107
|
-
f"agent plugin '{ep.name}': not a valid Agent subclass",
|
|
108
|
-
stacklevel=2,
|
|
109
|
-
)
|
|
110
|
-
continue
|
|
111
109
|
instance = cls()
|
|
112
110
|
agents[instance.name] = instance
|
|
113
111
|
except Exception as e:
|
|
114
112
|
warnings.warn(
|
|
115
|
-
f"failed to load agent plugin '{
|
|
113
|
+
f"failed to load agent plugin '{cls.__name__}': {e}",
|
|
116
114
|
stacklevel=2,
|
|
117
115
|
)
|
|
118
|
-
|
|
119
116
|
return agents
|
|
120
117
|
|
|
121
118
|
|
|
@@ -10,9 +10,11 @@ import subprocess
|
|
|
10
10
|
import sys
|
|
11
11
|
import tempfile
|
|
12
12
|
import traceback
|
|
13
|
+
import warnings
|
|
14
|
+
from importlib.metadata import version
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
|
-
from .agents import get_agents, get_agent
|
|
17
|
+
from .agents import Agent, get_agents, get_agent
|
|
16
18
|
from .lint import lint_all
|
|
17
19
|
from .render import (
|
|
18
20
|
NotFoundError,
|
|
@@ -26,8 +28,7 @@ from .render import (
|
|
|
26
28
|
EXIT_SUCCESS = 0
|
|
27
29
|
EXIT_ERROR = 1 # general error (agent failed, etc.)
|
|
28
30
|
EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
|
|
29
|
-
|
|
30
|
-
_VALID_SUBAGENT_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
31
|
+
_VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
31
32
|
|
|
32
33
|
|
|
33
34
|
class CliError(Exception):
|
|
@@ -41,6 +42,32 @@ class CliError(Exception):
|
|
|
41
42
|
self.exit_code = exit_code
|
|
42
43
|
|
|
43
44
|
|
|
45
|
+
def _agent_hint() -> str:
|
|
46
|
+
"""Return a hint string listing valid agent names."""
|
|
47
|
+
return f"valid agents: {', '.join(get_agents().keys())}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_agent(name: str) -> "Agent":
|
|
51
|
+
"""Look up an agent by name, raising CliError on unknown names."""
|
|
52
|
+
try:
|
|
53
|
+
return get_agent(name)
|
|
54
|
+
except KeyError as e:
|
|
55
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _write_file(path: Path, content: str, label: str = "") -> None:
|
|
59
|
+
"""Write content to path, creating parent dirs. Wraps OSError as CliError."""
|
|
60
|
+
prefix = f"{label} " if label else ""
|
|
61
|
+
try:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(content, encoding="utf-8")
|
|
64
|
+
except OSError as e:
|
|
65
|
+
raise CliError(
|
|
66
|
+
f"cannot write {prefix}{path}: {e}",
|
|
67
|
+
hint="check file permissions and disk space",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
44
71
|
def find_repo_root(start: Path) -> Path:
|
|
45
72
|
"""Find the repository root by looking for .git directory.
|
|
46
73
|
|
|
@@ -54,6 +81,31 @@ def find_repo_root(start: Path) -> Path:
|
|
|
54
81
|
return cur
|
|
55
82
|
|
|
56
83
|
|
|
84
|
+
def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
85
|
+
"""Best-effort removal of a git worktree."""
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(
|
|
88
|
+
[
|
|
89
|
+
"git",
|
|
90
|
+
"-C",
|
|
91
|
+
str(repo_root),
|
|
92
|
+
"worktree",
|
|
93
|
+
"remove",
|
|
94
|
+
"--force",
|
|
95
|
+
str(worktree_path),
|
|
96
|
+
],
|
|
97
|
+
capture_output=True,
|
|
98
|
+
)
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
stderr = result.stderr.decode(errors="replace").strip()
|
|
101
|
+
warnings.warn(
|
|
102
|
+
f"failed to remove worktree {worktree_path}: {stderr}",
|
|
103
|
+
stacklevel=2,
|
|
104
|
+
)
|
|
105
|
+
except FileNotFoundError:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
|
|
57
109
|
def create_worktree(repo_root: Path) -> Path:
|
|
58
110
|
"""Create a temporary git worktree for the repository."""
|
|
59
111
|
# Ensure it's a git repo
|
|
@@ -95,33 +147,27 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
95
147
|
|
|
96
148
|
|
|
97
149
|
def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
|
|
102
|
-
(repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
|
|
150
|
+
agent = _resolve_agent(agent_name)
|
|
151
|
+
dest = repo_root / agent.output_file
|
|
152
|
+
_write_file(dest, system.strip() + "\n")
|
|
103
153
|
|
|
104
154
|
|
|
105
155
|
def write_skill(
|
|
106
156
|
repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
|
|
107
157
|
) -> Path:
|
|
108
158
|
"""Write a reusable skill file for supported agents."""
|
|
109
|
-
if not
|
|
159
|
+
if not _VALID_NAME.match(skill_name):
|
|
110
160
|
raise CliError(
|
|
111
161
|
f"invalid skill name: {skill_name}",
|
|
112
162
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
113
163
|
)
|
|
114
164
|
|
|
115
|
-
|
|
116
|
-
agent = get_agent(agent_name)
|
|
117
|
-
except KeyError as e:
|
|
118
|
-
raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
|
|
165
|
+
agent = _resolve_agent(agent_name)
|
|
119
166
|
|
|
120
167
|
if not agent.supports_skills():
|
|
121
168
|
raise CliError(f"agent does not support skills: {agent_name}")
|
|
122
169
|
|
|
123
170
|
skill_path = agent.skill_path(repo_root, skill_name)
|
|
124
|
-
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
171
|
content = (
|
|
126
172
|
f"# {skill_name}\n\n"
|
|
127
173
|
"use this skill when this recurring task appears in this repository.\n\n"
|
|
@@ -131,7 +177,7 @@ def write_skill(
|
|
|
131
177
|
"## instructions\n\n"
|
|
132
178
|
f"{prompt.strip()}\n"
|
|
133
179
|
)
|
|
134
|
-
skill_path
|
|
180
|
+
_write_file(skill_path, content, label="skill")
|
|
135
181
|
return skill_path
|
|
136
182
|
|
|
137
183
|
|
|
@@ -143,22 +189,18 @@ def write_subagent(
|
|
|
143
189
|
system: str,
|
|
144
190
|
) -> Path:
|
|
145
191
|
"""Write a reusable subagent file for supported agents."""
|
|
146
|
-
if not
|
|
192
|
+
if not _VALID_NAME.match(subagent_name):
|
|
147
193
|
raise CliError(
|
|
148
194
|
f"invalid subagent name: {subagent_name}",
|
|
149
195
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
150
196
|
)
|
|
151
197
|
|
|
152
|
-
|
|
153
|
-
agent = get_agent(agent_name)
|
|
154
|
-
except KeyError as e:
|
|
155
|
-
raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
|
|
198
|
+
agent = _resolve_agent(agent_name)
|
|
156
199
|
|
|
157
200
|
if not agent.supports_subagents():
|
|
158
201
|
raise CliError(f"agent does not support subagents: {agent_name}")
|
|
159
202
|
|
|
160
203
|
subagent_path = agent.subagent_path(repo_root, subagent_name)
|
|
161
|
-
subagent_path.parent.mkdir(parents=True, exist_ok=True)
|
|
162
204
|
profile = ", ".join(conjurings)
|
|
163
205
|
content = (
|
|
164
206
|
f"# {subagent_name}\n\n"
|
|
@@ -169,7 +211,7 @@ def write_subagent(
|
|
|
169
211
|
"## instructions\n\n"
|
|
170
212
|
f"{system.strip()}\n"
|
|
171
213
|
)
|
|
172
|
-
subagent_path
|
|
214
|
+
_write_file(subagent_path, content, label="subagent")
|
|
173
215
|
return subagent_path
|
|
174
216
|
|
|
175
217
|
|
|
@@ -189,10 +231,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
|
|
|
189
231
|
def run_agent(
|
|
190
232
|
repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
|
|
191
233
|
) -> int:
|
|
192
|
-
|
|
193
|
-
agent = get_agent(agent_name)
|
|
194
|
-
except KeyError as e:
|
|
195
|
-
raise CliError(str(e), hint=f"valid agents: {', '.join(get_agents().keys())}")
|
|
234
|
+
agent = _resolve_agent(agent_name)
|
|
196
235
|
try:
|
|
197
236
|
return agent.run(repo_root, prompt, headless, auto=auto)
|
|
198
237
|
except FileNotFoundError:
|
|
@@ -258,8 +297,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
258
297
|
instr_file = repo_root / agent.output_file
|
|
259
298
|
if instr_file.exists():
|
|
260
299
|
shutil.copy2(instr_file, target_dir / agent.output_file)
|
|
261
|
-
except (OSError, KeyError):
|
|
262
|
-
|
|
300
|
+
except (OSError, KeyError) as exc:
|
|
301
|
+
warnings.warn(
|
|
302
|
+
f"failed to copy instruction file into worktree: {exc}",
|
|
303
|
+
stacklevel=2,
|
|
304
|
+
)
|
|
263
305
|
|
|
264
306
|
try:
|
|
265
307
|
rc = run_agent(
|
|
@@ -271,7 +313,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
271
313
|
return rc
|
|
272
314
|
except Exception:
|
|
273
315
|
if args.worktree:
|
|
274
|
-
|
|
316
|
+
_remove_worktree(repo_root, target_dir)
|
|
275
317
|
raise
|
|
276
318
|
|
|
277
319
|
|
|
@@ -357,6 +399,11 @@ def main() -> None:
|
|
|
357
399
|
" familiar lint # validate prompts\n",
|
|
358
400
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
359
401
|
)
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--version",
|
|
404
|
+
action="version",
|
|
405
|
+
version=f"%(prog)s {version('familiar-cli')}",
|
|
406
|
+
)
|
|
360
407
|
parser.add_argument(
|
|
361
408
|
"--debug", action="store_true", help="show full traceback on error"
|
|
362
409
|
)
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
-
import warnings
|
|
7
6
|
from dataclasses import dataclass
|
|
8
|
-
from importlib.metadata import entry_points
|
|
9
7
|
from pathlib import Path
|
|
10
8
|
from typing import Callable, Literal
|
|
11
9
|
|
|
10
|
+
from ._plugins import load_plugins
|
|
11
|
+
|
|
12
12
|
from .render import _SNIPPET_INCLUDE, NotFoundError, list_items, load_snippet, load_text
|
|
13
13
|
|
|
14
14
|
|
|
@@ -202,27 +202,12 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
|
|
|
202
202
|
Returns:
|
|
203
203
|
List of linter functions from plugins.
|
|
204
204
|
"""
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
func = ep.load()
|
|
212
|
-
if not callable(func):
|
|
213
|
-
warnings.warn(
|
|
214
|
-
f"linter plugin '{ep.name}': not callable",
|
|
215
|
-
stacklevel=2,
|
|
216
|
-
)
|
|
217
|
-
continue
|
|
218
|
-
linters.append(func)
|
|
219
|
-
except Exception as e:
|
|
220
|
-
warnings.warn(
|
|
221
|
-
f"failed to load linter plugin '{ep.name}': {e}",
|
|
222
|
-
stacklevel=2,
|
|
223
|
-
)
|
|
224
|
-
|
|
225
|
-
return linters
|
|
205
|
+
return load_plugins(
|
|
206
|
+
f"familiar.linters.{kind}",
|
|
207
|
+
callable,
|
|
208
|
+
label="linter plugin",
|
|
209
|
+
invalid_msg="not callable",
|
|
210
|
+
)
|
|
226
211
|
|
|
227
212
|
|
|
228
213
|
def lint_snippet_references(
|
|
@@ -280,7 +265,7 @@ def lint_collection(
|
|
|
280
265
|
message=f"plugin linter failed: {e}",
|
|
281
266
|
)
|
|
282
267
|
)
|
|
283
|
-
except
|
|
268
|
+
except NotFoundError as e:
|
|
284
269
|
messages.append(
|
|
285
270
|
LintMessage(
|
|
286
271
|
level="error",
|
|
@@ -21,13 +21,23 @@ class NotFoundError(Exception):
|
|
|
21
21
|
"""Raised when a conjuring, invocation, or snippet is not found."""
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _safe_read_text(path: Path, label: str) -> str:
|
|
25
|
+
"""Read a UTF-8 text file, converting I/O errors to :class:`NotFoundError`."""
|
|
26
|
+
try:
|
|
27
|
+
return path.read_text(encoding="utf-8")
|
|
28
|
+
except PermissionError:
|
|
29
|
+
raise NotFoundError(f"cannot read {label}: permission denied on {path}")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
raise NotFoundError(f"cannot read {label}: {path} is not valid UTF-8")
|
|
32
|
+
|
|
33
|
+
|
|
24
34
|
def load_text(repo_root: Path, kind: str, name: str) -> str:
|
|
25
35
|
"""Load a conjuring or invocation; local overrides in .familiar override package data."""
|
|
26
36
|
if not _VALID_NAME.match(name):
|
|
27
37
|
raise NotFoundError(f"invalid {kind.rstrip('s')} name: {name}")
|
|
28
38
|
override = repo_root / ".familiar" / kind / f"{name}.md"
|
|
29
39
|
if override.exists():
|
|
30
|
-
return override.
|
|
40
|
+
return _safe_read_text(override, f"{kind.rstrip('s')} '{name}'")
|
|
31
41
|
pkg = f"familiar.data.{kind}"
|
|
32
42
|
try:
|
|
33
43
|
return (resources.files(pkg) / f"{name}.md").read_text(encoding="utf-8")
|
|
@@ -47,7 +57,7 @@ def load_snippet(repo_root: Path, path: str) -> str:
|
|
|
47
57
|
|
|
48
58
|
override = repo_root / ".familiar" / "snippets" / path
|
|
49
59
|
if override.exists():
|
|
50
|
-
return override
|
|
60
|
+
return _safe_read_text(override, f"snippet '{path}'")
|
|
51
61
|
|
|
52
62
|
try:
|
|
53
63
|
ref: Traversable = resources.files("familiar.data.snippets")
|
|
@@ -110,7 +120,10 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
|
|
|
110
120
|
if item.is_dir():
|
|
111
121
|
items.extend(_walk_traversable(item, rel))
|
|
112
122
|
elif not item.name.startswith("_"):
|
|
113
|
-
|
|
123
|
+
try:
|
|
124
|
+
content = item.read_text(encoding="utf-8")
|
|
125
|
+
except (UnicodeDecodeError, PermissionError):
|
|
126
|
+
continue
|
|
114
127
|
first_line = content.split("\n", 1)[0].strip()
|
|
115
128
|
items.append((rel, first_line))
|
|
116
129
|
except (FileNotFoundError, TypeError):
|
|
@@ -118,71 +131,91 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
|
|
|
118
131
|
return items
|
|
119
132
|
|
|
120
133
|
|
|
121
|
-
def
|
|
122
|
-
|
|
134
|
+
def _list_resources(
|
|
135
|
+
repo_root: Path,
|
|
136
|
+
pkg: str,
|
|
137
|
+
local_subdir: str,
|
|
138
|
+
*,
|
|
139
|
+
recursive: bool = False,
|
|
140
|
+
suffix: str = "",
|
|
141
|
+
) -> list[tuple[str, str, bool]]:
|
|
142
|
+
"""List resources from package data and local overrides.
|
|
123
143
|
|
|
124
|
-
|
|
144
|
+
Items from local ``.familiar/{local_subdir}/`` override package builtins.
|
|
145
|
+
|
|
146
|
+
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.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
Sorted list of ``(key, first_line, is_local)`` tuples.
|
|
125
155
|
"""
|
|
126
156
|
items: dict[str, tuple[str, bool]] = {}
|
|
127
157
|
|
|
128
|
-
# built-
|
|
158
|
+
# built-in resources from package data
|
|
129
159
|
try:
|
|
130
|
-
pkg_root = resources.files(
|
|
131
|
-
|
|
132
|
-
|
|
160
|
+
pkg_root = resources.files(pkg)
|
|
161
|
+
if recursive:
|
|
162
|
+
for rel, first_line in _walk_traversable(pkg_root):
|
|
163
|
+
items[rel] = (first_line, False)
|
|
164
|
+
else:
|
|
165
|
+
for item in pkg_root.iterdir():
|
|
166
|
+
if suffix and not item.name.endswith(suffix):
|
|
167
|
+
continue
|
|
168
|
+
if item.name.startswith("_"):
|
|
169
|
+
continue
|
|
170
|
+
key = item.name[: -len(suffix)] if suffix else item.name
|
|
171
|
+
try:
|
|
172
|
+
content = item.read_text(encoding="utf-8")
|
|
173
|
+
except (UnicodeDecodeError, PermissionError):
|
|
174
|
+
continue
|
|
175
|
+
first_line = content.split("\n", 1)[0].strip()
|
|
176
|
+
items[key] = (first_line, False)
|
|
133
177
|
except (FileNotFoundError, TypeError, ModuleNotFoundError):
|
|
134
178
|
pass
|
|
135
179
|
|
|
136
180
|
# local overrides
|
|
137
|
-
local_dir = repo_root / ".familiar" /
|
|
181
|
+
local_dir = repo_root / ".familiar" / local_subdir
|
|
138
182
|
if local_dir.is_dir():
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
183
|
+
files = (
|
|
184
|
+
sorted(local_dir.rglob("*")) if recursive else local_dir.glob(f"*{suffix}")
|
|
185
|
+
)
|
|
186
|
+
for f in files:
|
|
187
|
+
if not f.is_file() or f.name.startswith("_"):
|
|
188
|
+
continue
|
|
189
|
+
key = str(f.relative_to(local_dir)) if recursive else f.stem
|
|
190
|
+
try:
|
|
142
191
|
content = f.read_text(encoding="utf-8")
|
|
143
|
-
|
|
144
|
-
|
|
192
|
+
except (UnicodeDecodeError, PermissionError):
|
|
193
|
+
continue
|
|
194
|
+
first_line = content.split("\n", 1)[0].strip()
|
|
195
|
+
items[key] = (first_line, True)
|
|
145
196
|
|
|
146
197
|
return [
|
|
147
|
-
(
|
|
148
|
-
for
|
|
198
|
+
(key, first_line, is_local)
|
|
199
|
+
for key, (first_line, is_local) in sorted(items.items())
|
|
149
200
|
]
|
|
150
201
|
|
|
151
202
|
|
|
152
|
-
def
|
|
153
|
-
"""List available
|
|
203
|
+
def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
|
|
204
|
+
"""List available snippets.
|
|
154
205
|
|
|
155
|
-
Returns list of (
|
|
206
|
+
Returns list of (path, first_line, is_local) tuples, sorted by path.
|
|
156
207
|
"""
|
|
157
|
-
|
|
208
|
+
return _list_resources(
|
|
209
|
+
repo_root, "familiar.data.snippets", "snippets", recursive=True
|
|
210
|
+
)
|
|
158
211
|
|
|
159
|
-
# built-ins
|
|
160
|
-
pkg = f"familiar.data.{kind}"
|
|
161
|
-
try:
|
|
162
|
-
pkg_files = resources.files(pkg)
|
|
163
|
-
for item in pkg_files.iterdir():
|
|
164
|
-
if item.name.endswith(".md") and not item.name.startswith("_"):
|
|
165
|
-
name = item.name[:-3]
|
|
166
|
-
content = item.read_text(encoding="utf-8")
|
|
167
|
-
first_line = content.split("\n", 1)[0].strip()
|
|
168
|
-
items[name] = (first_line, False)
|
|
169
|
-
except (FileNotFoundError, TypeError):
|
|
170
|
-
pass
|
|
171
212
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if local_dir.is_dir():
|
|
175
|
-
for f in local_dir.glob("*.md"):
|
|
176
|
-
if not f.name.startswith("_"):
|
|
177
|
-
name = f.stem
|
|
178
|
-
content = f.read_text(encoding="utf-8")
|
|
179
|
-
first_line = content.split("\n", 1)[0].strip()
|
|
180
|
-
items[name] = (first_line, True)
|
|
213
|
+
def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
|
|
214
|
+
"""List available conjurings or invocations.
|
|
181
215
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
]
|
|
216
|
+
Returns list of (name, first_line, is_local) tuples, sorted by name.
|
|
217
|
+
"""
|
|
218
|
+
return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
|
|
186
219
|
|
|
187
220
|
|
|
188
221
|
def compose_system(repo_root: Path, conjurings: list[str]) -> str:
|
|
@@ -68,7 +68,7 @@ class TestPluginLoadErrors:
|
|
|
68
68
|
mock_ep.name = "invalid"
|
|
69
69
|
mock_ep.load.return_value = str # not an Agent subclass
|
|
70
70
|
|
|
71
|
-
with patch("familiar.
|
|
71
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
72
72
|
with pytest.warns(UserWarning, match="not a valid Agent subclass"):
|
|
73
73
|
agents = load_agents()
|
|
74
74
|
assert "invalid" not in agents
|
|
@@ -79,7 +79,7 @@ class TestPluginLoadErrors:
|
|
|
79
79
|
mock_ep.name = "broken"
|
|
80
80
|
mock_ep.load.side_effect = ImportError("module not found")
|
|
81
81
|
|
|
82
|
-
with patch("familiar.
|
|
82
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
83
83
|
with pytest.warns(UserWarning, match="failed to load"):
|
|
84
84
|
agents = load_agents()
|
|
85
85
|
assert "broken" not in agents
|
|
@@ -101,7 +101,7 @@ class TestPluginLoadErrors:
|
|
|
101
101
|
mock_ep.name = "bad"
|
|
102
102
|
mock_ep.load.return_value = BadAgent
|
|
103
103
|
|
|
104
|
-
with patch("familiar.
|
|
104
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
105
105
|
with pytest.warns(UserWarning, match="failed to load"):
|
|
106
106
|
agents = load_agents()
|
|
107
107
|
assert "bad" not in agents
|
|
@@ -8,11 +8,17 @@ from unittest.mock import patch
|
|
|
8
8
|
import argparse
|
|
9
9
|
|
|
10
10
|
from familiar.cli import (
|
|
11
|
+
_remove_worktree,
|
|
12
|
+
_resolve_agent,
|
|
13
|
+
_write_file,
|
|
11
14
|
find_repo_root,
|
|
12
15
|
create_worktree,
|
|
13
16
|
write_instruction,
|
|
17
|
+
write_skill,
|
|
18
|
+
write_subagent,
|
|
14
19
|
parse_kv,
|
|
15
20
|
run_agent,
|
|
21
|
+
main,
|
|
16
22
|
cmd_conjure,
|
|
17
23
|
cmd_invoke,
|
|
18
24
|
cmd_list,
|
|
@@ -21,6 +27,19 @@ from familiar.cli import (
|
|
|
21
27
|
)
|
|
22
28
|
|
|
23
29
|
|
|
30
|
+
class TestVersion:
|
|
31
|
+
"""tests for --version flag."""
|
|
32
|
+
|
|
33
|
+
def test_version_prints_and_exits(self, capsys):
|
|
34
|
+
with pytest.raises(SystemExit, match="0"):
|
|
35
|
+
with patch("sys.argv", ["familiar", "--version"]):
|
|
36
|
+
main()
|
|
37
|
+
captured = capsys.readouterr()
|
|
38
|
+
assert "familiar" in captured.out
|
|
39
|
+
# version string should look like a semver
|
|
40
|
+
assert any(c.isdigit() for c in captured.out)
|
|
41
|
+
|
|
42
|
+
|
|
24
43
|
class TestCreateWorktree:
|
|
25
44
|
"""tests for git worktree creation."""
|
|
26
45
|
|
|
@@ -70,6 +89,50 @@ class TestFindRepoRoot:
|
|
|
70
89
|
assert result == subdir.resolve()
|
|
71
90
|
|
|
72
91
|
|
|
92
|
+
class TestResolveAgent:
|
|
93
|
+
"""tests for _resolve_agent helper."""
|
|
94
|
+
|
|
95
|
+
def test_returns_known_agent(self):
|
|
96
|
+
agent = _resolve_agent("claude")
|
|
97
|
+
assert agent.name == "claude"
|
|
98
|
+
|
|
99
|
+
def test_unknown_agent_raises(self):
|
|
100
|
+
with pytest.raises(CliError, match="unknown agent"):
|
|
101
|
+
_resolve_agent("nonexistent")
|
|
102
|
+
|
|
103
|
+
def test_unknown_agent_includes_hint(self):
|
|
104
|
+
with pytest.raises(CliError) as exc_info:
|
|
105
|
+
_resolve_agent("nonexistent")
|
|
106
|
+
assert exc_info.value.hint is not None
|
|
107
|
+
assert "valid agents" in exc_info.value.hint
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestWriteFile:
|
|
111
|
+
"""tests for _write_file helper."""
|
|
112
|
+
|
|
113
|
+
def test_writes_content(self, tmp_path):
|
|
114
|
+
dest = tmp_path / "out.txt"
|
|
115
|
+
_write_file(dest, "hello\n")
|
|
116
|
+
assert dest.read_text() == "hello\n"
|
|
117
|
+
|
|
118
|
+
def test_creates_parent_dirs(self, tmp_path):
|
|
119
|
+
dest = tmp_path / "a" / "b" / "out.txt"
|
|
120
|
+
_write_file(dest, "nested\n")
|
|
121
|
+
assert dest.read_text() == "nested\n"
|
|
122
|
+
|
|
123
|
+
def test_oserror_without_label(self, tmp_path):
|
|
124
|
+
dest = tmp_path / "out.txt"
|
|
125
|
+
with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
|
|
126
|
+
with pytest.raises(CliError, match="cannot write .*/out.txt"):
|
|
127
|
+
_write_file(dest, "content")
|
|
128
|
+
|
|
129
|
+
def test_oserror_with_label(self, tmp_path):
|
|
130
|
+
dest = tmp_path / "out.txt"
|
|
131
|
+
with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
|
|
132
|
+
with pytest.raises(CliError, match="cannot write skill .*/out.txt"):
|
|
133
|
+
_write_file(dest, "content", label="skill")
|
|
134
|
+
|
|
135
|
+
|
|
73
136
|
class TestWriteInstruction:
|
|
74
137
|
"""tests for writing instruction files."""
|
|
75
138
|
|
|
@@ -86,6 +149,48 @@ class TestWriteInstruction:
|
|
|
86
149
|
assert (tmp_path / "CLAUDE.md").read_text() == "content\n"
|
|
87
150
|
|
|
88
151
|
|
|
152
|
+
class TestRemoveWorktree:
|
|
153
|
+
"""tests for git worktree removal."""
|
|
154
|
+
|
|
155
|
+
def test_warns_on_nonzero_return_code(self, tmp_path):
|
|
156
|
+
with patch("subprocess.run") as mock_run:
|
|
157
|
+
mock_run.return_value = argparse.Namespace(
|
|
158
|
+
returncode=1, stderr=b"worktree not found"
|
|
159
|
+
)
|
|
160
|
+
with pytest.warns(UserWarning, match="failed to remove worktree"):
|
|
161
|
+
_remove_worktree(tmp_path, tmp_path / "wt")
|
|
162
|
+
|
|
163
|
+
def test_no_warning_on_success(self, tmp_path, recwarn):
|
|
164
|
+
with patch("subprocess.run") as mock_run:
|
|
165
|
+
mock_run.return_value = argparse.Namespace(returncode=0, stderr=b"")
|
|
166
|
+
_remove_worktree(tmp_path, tmp_path / "wt")
|
|
167
|
+
assert len(recwarn) == 0
|
|
168
|
+
|
|
169
|
+
def test_silent_on_missing_git(self, tmp_path, recwarn):
|
|
170
|
+
with patch("subprocess.run", side_effect=FileNotFoundError):
|
|
171
|
+
_remove_worktree(tmp_path, tmp_path / "wt")
|
|
172
|
+
assert len(recwarn) == 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class TestWriteFailures:
|
|
176
|
+
"""tests for write error handling."""
|
|
177
|
+
|
|
178
|
+
def test_write_instruction_permission_denied(self, tmp_path):
|
|
179
|
+
with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
|
|
180
|
+
with pytest.raises(CliError, match="cannot write"):
|
|
181
|
+
write_instruction(tmp_path, "claude", "content")
|
|
182
|
+
|
|
183
|
+
def test_write_skill_permission_denied(self, tmp_path):
|
|
184
|
+
with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
|
|
185
|
+
with pytest.raises(CliError, match="cannot write skill"):
|
|
186
|
+
write_skill(tmp_path, "claude", "explain", "my-skill", "prompt")
|
|
187
|
+
|
|
188
|
+
def test_write_subagent_permission_denied(self, tmp_path):
|
|
189
|
+
with patch("pathlib.Path.write_text", side_effect=PermissionError("denied")):
|
|
190
|
+
with pytest.raises(CliError, match="cannot write subagent"):
|
|
191
|
+
write_subagent(tmp_path, "claude", ["python"], "my-subagent", "system")
|
|
192
|
+
|
|
193
|
+
|
|
89
194
|
class TestParseKv:
|
|
90
195
|
"""tests for key-value parsing."""
|
|
91
196
|
|
|
@@ -329,6 +434,27 @@ class TestCmdInvoke:
|
|
|
329
434
|
# Check that instruction file was copied
|
|
330
435
|
mock_copy.assert_called_once()
|
|
331
436
|
|
|
437
|
+
def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
|
|
438
|
+
with patch("familiar.cli.create_worktree", return_value=tmp_path / "wt"):
|
|
439
|
+
with patch("familiar.cli.run_agent", side_effect=RuntimeError("boom")):
|
|
440
|
+
with patch("familiar.cli._remove_worktree") as mock_remove:
|
|
441
|
+
args = argparse.Namespace(
|
|
442
|
+
agent="claude",
|
|
443
|
+
invocation="explain",
|
|
444
|
+
into=str(tmp_path),
|
|
445
|
+
headless=True,
|
|
446
|
+
auto=False,
|
|
447
|
+
dry_run=False,
|
|
448
|
+
kv=None,
|
|
449
|
+
inv_args=[],
|
|
450
|
+
worktree=True,
|
|
451
|
+
save_skill=False,
|
|
452
|
+
skill_name=None,
|
|
453
|
+
)
|
|
454
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
455
|
+
cmd_invoke(args)
|
|
456
|
+
mock_remove.assert_called_once()
|
|
457
|
+
|
|
332
458
|
def test_invoke_save_skill_claude(self, tmp_path):
|
|
333
459
|
with patch("familiar.cli.run_agent") as mock_run:
|
|
334
460
|
args = argparse.Namespace(
|
|
@@ -9,11 +9,13 @@ import pytest
|
|
|
9
9
|
from familiar.lint import (
|
|
10
10
|
LintMessage,
|
|
11
11
|
lint_all,
|
|
12
|
+
lint_collection,
|
|
12
13
|
lint_invocation,
|
|
13
14
|
lint_snippet_references,
|
|
14
15
|
lint_template,
|
|
15
16
|
load_linters,
|
|
16
17
|
)
|
|
18
|
+
from familiar.render import NotFoundError
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class TestLintTemplate:
|
|
@@ -225,7 +227,7 @@ class TestLinterPlugins:
|
|
|
225
227
|
mock_ep.name = "invalid"
|
|
226
228
|
mock_ep.load.return_value = "not a function"
|
|
227
229
|
|
|
228
|
-
with patch("familiar.
|
|
230
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
229
231
|
with pytest.warns(UserWarning, match="not callable"):
|
|
230
232
|
linters = load_linters("conjurings")
|
|
231
233
|
assert len(linters) == 0
|
|
@@ -236,7 +238,7 @@ class TestLinterPlugins:
|
|
|
236
238
|
mock_ep.name = "broken"
|
|
237
239
|
mock_ep.load.side_effect = ImportError("module not found")
|
|
238
240
|
|
|
239
|
-
with patch("familiar.
|
|
241
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
240
242
|
with pytest.warns(UserWarning, match="failed to load"):
|
|
241
243
|
linters = load_linters("conjurings")
|
|
242
244
|
assert len(linters) == 0
|
|
@@ -258,7 +260,7 @@ class TestLinterPlugins:
|
|
|
258
260
|
templates.mkdir(parents=True)
|
|
259
261
|
(templates / "mytemplate.md").write_text("# test")
|
|
260
262
|
|
|
261
|
-
with patch("familiar.
|
|
263
|
+
with patch("familiar._plugins.entry_points") as mock_entry_points:
|
|
262
264
|
# Return our mock for templates, empty for invocations
|
|
263
265
|
def ep_side_effect(group):
|
|
264
266
|
if group == "familiar.linters.conjurings":
|
|
@@ -287,7 +289,7 @@ class TestLinterPlugins:
|
|
|
287
289
|
templates.mkdir(parents=True)
|
|
288
290
|
(templates / "test.md").write_text("# test")
|
|
289
291
|
|
|
290
|
-
with patch("familiar.
|
|
292
|
+
with patch("familiar._plugins.entry_points") as mock_entry_points:
|
|
291
293
|
|
|
292
294
|
def ep_side_effect(group):
|
|
293
295
|
if group == "familiar.linters.conjurings":
|
|
@@ -301,6 +303,48 @@ class TestLinterPlugins:
|
|
|
301
303
|
assert len(error_messages) >= 1
|
|
302
304
|
|
|
303
305
|
|
|
306
|
+
class TestLintCollectionErrorHandling:
|
|
307
|
+
"""tests for error handling in lint_collection."""
|
|
308
|
+
|
|
309
|
+
def test_load_error_produces_lint_error(self, tmp_path):
|
|
310
|
+
"""A NotFoundError from load_text should produce a lint error, not crash."""
|
|
311
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
312
|
+
templates.mkdir(parents=True)
|
|
313
|
+
(templates / "broken.md").write_text("# heading")
|
|
314
|
+
|
|
315
|
+
from familiar import lint as _lint_mod
|
|
316
|
+
|
|
317
|
+
original = _lint_mod.load_text
|
|
318
|
+
|
|
319
|
+
def patched(repo_root, kind, name):
|
|
320
|
+
if name == "broken":
|
|
321
|
+
raise NotFoundError("simulated read failure")
|
|
322
|
+
return original(repo_root, kind, name)
|
|
323
|
+
|
|
324
|
+
with patch.object(_lint_mod, "load_text", side_effect=patched):
|
|
325
|
+
messages = lint_collection(tmp_path, "conjurings", lint_template, [])
|
|
326
|
+
|
|
327
|
+
load_errors = [
|
|
328
|
+
m
|
|
329
|
+
for m in messages
|
|
330
|
+
if "broken.md" in m.file and "failed to load" in m.message
|
|
331
|
+
]
|
|
332
|
+
assert len(load_errors) == 1
|
|
333
|
+
assert load_errors[0].level == "error"
|
|
334
|
+
|
|
335
|
+
def test_unexpected_linter_error_propagates(self, tmp_path):
|
|
336
|
+
"""Errors not wrapped in NotFoundError should propagate, not be swallowed."""
|
|
337
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
338
|
+
templates.mkdir(parents=True)
|
|
339
|
+
(templates / "test.md").write_text("# test")
|
|
340
|
+
|
|
341
|
+
def exploding_linter(content: str, name: str) -> list[LintMessage]:
|
|
342
|
+
raise RuntimeError("unexpected")
|
|
343
|
+
|
|
344
|
+
with pytest.raises(RuntimeError, match="unexpected"):
|
|
345
|
+
lint_collection(tmp_path, "conjurings", exploding_linter, [])
|
|
346
|
+
|
|
347
|
+
|
|
304
348
|
class TestLintSnippetReferences:
|
|
305
349
|
"""tests for snippet reference validation."""
|
|
306
350
|
|
|
@@ -100,6 +100,25 @@ class TestLoadText:
|
|
|
100
100
|
with pytest.raises(NotFoundError, match="unknown invocation"):
|
|
101
101
|
load_text(tmp_path, "invocations", "nonexistent")
|
|
102
102
|
|
|
103
|
+
def test_permission_denied_raises(self, tmp_path):
|
|
104
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
105
|
+
override_dir.mkdir(parents=True)
|
|
106
|
+
f = override_dir / "locked.md"
|
|
107
|
+
f.write_text("content")
|
|
108
|
+
f.chmod(0o000)
|
|
109
|
+
try:
|
|
110
|
+
with pytest.raises(NotFoundError, match="permission denied"):
|
|
111
|
+
load_text(tmp_path, "conjurings", "locked")
|
|
112
|
+
finally:
|
|
113
|
+
f.chmod(0o644)
|
|
114
|
+
|
|
115
|
+
def test_invalid_utf8_raises(self, tmp_path):
|
|
116
|
+
override_dir = tmp_path / ".familiar" / "conjurings"
|
|
117
|
+
override_dir.mkdir(parents=True)
|
|
118
|
+
(override_dir / "binary.md").write_bytes(b"\xff\xfe invalid utf-8")
|
|
119
|
+
with pytest.raises(NotFoundError, match="not valid UTF-8"):
|
|
120
|
+
load_text(tmp_path, "conjurings", "binary")
|
|
121
|
+
|
|
103
122
|
|
|
104
123
|
class TestComposeSystem:
|
|
105
124
|
"""tests for composing system prompts."""
|
|
@@ -121,6 +140,34 @@ class TestComposeSystem:
|
|
|
121
140
|
# verify order: core, then conjurings in order
|
|
122
141
|
assert system == "CORE\n\nFIRST\n\nSECOND"
|
|
123
142
|
|
|
143
|
+
def test_compose_core_only(self, tmp_path):
|
|
144
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
145
|
+
templates.mkdir(parents=True)
|
|
146
|
+
(templates / "core.md").write_text("CORE")
|
|
147
|
+
|
|
148
|
+
system = compose_system(tmp_path, [])
|
|
149
|
+
assert system == "CORE"
|
|
150
|
+
|
|
151
|
+
def test_compose_multiple_conjurings(self, tmp_path):
|
|
152
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
153
|
+
templates.mkdir(parents=True)
|
|
154
|
+
(templates / "core.md").write_text("CORE")
|
|
155
|
+
(templates / "a.md").write_text("A")
|
|
156
|
+
(templates / "b.md").write_text("B")
|
|
157
|
+
(templates / "c.md").write_text("C")
|
|
158
|
+
|
|
159
|
+
system = compose_system(tmp_path, ["a", "b", "c"])
|
|
160
|
+
assert system == "CORE\n\nA\n\nB\n\nC"
|
|
161
|
+
|
|
162
|
+
def test_compose_strips_whitespace(self, tmp_path):
|
|
163
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
164
|
+
templates.mkdir(parents=True)
|
|
165
|
+
(templates / "core.md").write_text(" CORE \n")
|
|
166
|
+
(templates / "pad.md").write_text("\n PAD \n\n")
|
|
167
|
+
|
|
168
|
+
system = compose_system(tmp_path, ["pad"])
|
|
169
|
+
assert system == "CORE\n\nPAD"
|
|
170
|
+
|
|
124
171
|
def test_compose_missing_profile_raises(self, tmp_path):
|
|
125
172
|
with pytest.raises(NotFoundError, match="unknown conjuring"):
|
|
126
173
|
compose_system(tmp_path, ["nonexistent"])
|
|
@@ -137,6 +184,36 @@ class TestRenderInvocation:
|
|
|
137
184
|
result = render_invocation(tmp_path, "greet", ["world"], {"style": "friendly"})
|
|
138
185
|
assert result == "hello world, friendly"
|
|
139
186
|
|
|
187
|
+
def test_render_with_snippet_and_args(self, tmp_path):
|
|
188
|
+
snippets = tmp_path / ".familiar" / "snippets" / "test"
|
|
189
|
+
snippets.mkdir(parents=True)
|
|
190
|
+
(snippets / "header.txt").write_text("== $1 ==")
|
|
191
|
+
|
|
192
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
193
|
+
invocations.mkdir(parents=True)
|
|
194
|
+
(invocations / "demo.md").write_text(
|
|
195
|
+
"{{> snippet:test/header.txt}}\nbody: {{mode}}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
result = render_invocation(tmp_path, "demo", ["title"], {"mode": "fast"})
|
|
199
|
+
assert result == "== title ==\nbody: fast"
|
|
200
|
+
|
|
201
|
+
def test_render_no_args(self, tmp_path):
|
|
202
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
203
|
+
invocations.mkdir(parents=True)
|
|
204
|
+
(invocations / "plain.md").write_text("no placeholders here")
|
|
205
|
+
|
|
206
|
+
result = render_invocation(tmp_path, "plain", [], {})
|
|
207
|
+
assert result == "no placeholders here"
|
|
208
|
+
|
|
209
|
+
def test_render_arguments_placeholder(self, tmp_path):
|
|
210
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
211
|
+
invocations.mkdir(parents=True)
|
|
212
|
+
(invocations / "all.md").write_text("run $ARGUMENTS")
|
|
213
|
+
|
|
214
|
+
result = render_invocation(tmp_path, "all", ["a", "b", "c"], {})
|
|
215
|
+
assert result == "run a b c"
|
|
216
|
+
|
|
140
217
|
def test_render_missing_invocation_raises(self, tmp_path):
|
|
141
218
|
with pytest.raises(NotFoundError, match="unknown invocation"):
|
|
142
219
|
render_invocation(tmp_path, "nonexistent", [], {})
|
|
@@ -204,6 +281,32 @@ class TestListItems:
|
|
|
204
281
|
items = list_items(tmp_path, "conjurings")
|
|
205
282
|
assert len(items) > 0
|
|
206
283
|
|
|
284
|
+
def test_list_skips_invalid_utf8_local(self, tmp_path):
|
|
285
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
286
|
+
templates.mkdir(parents=True)
|
|
287
|
+
(templates / "good.md").write_text("# good file")
|
|
288
|
+
(templates / "bad.md").write_bytes(b"\xff\xfe not utf-8")
|
|
289
|
+
|
|
290
|
+
items = list_items(tmp_path, "conjurings")
|
|
291
|
+
names = [name for name, _, _ in items]
|
|
292
|
+
assert "good" in names
|
|
293
|
+
assert "bad" not in names
|
|
294
|
+
|
|
295
|
+
def test_list_skips_permission_denied_local(self, tmp_path):
|
|
296
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
297
|
+
templates.mkdir(parents=True)
|
|
298
|
+
(templates / "good.md").write_text("# good file")
|
|
299
|
+
locked = templates / "locked.md"
|
|
300
|
+
locked.write_text("content")
|
|
301
|
+
locked.chmod(0o000)
|
|
302
|
+
try:
|
|
303
|
+
items = list_items(tmp_path, "conjurings")
|
|
304
|
+
names = [name for name, _, _ in items]
|
|
305
|
+
assert "good" in names
|
|
306
|
+
assert "locked" not in names
|
|
307
|
+
finally:
|
|
308
|
+
locked.chmod(0o644)
|
|
309
|
+
|
|
207
310
|
|
|
208
311
|
class TestLoadSnippet:
|
|
209
312
|
"""tests for loading snippets."""
|
|
@@ -231,6 +334,25 @@ class TestLoadSnippet:
|
|
|
231
334
|
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
232
335
|
load_snippet(tmp_path, "nonexistent/file.txt")
|
|
233
336
|
|
|
337
|
+
def test_permission_denied_raises(self, tmp_path):
|
|
338
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
339
|
+
snippet_dir.mkdir(parents=True)
|
|
340
|
+
f = snippet_dir / "locked.txt"
|
|
341
|
+
f.write_text("content")
|
|
342
|
+
f.chmod(0o000)
|
|
343
|
+
try:
|
|
344
|
+
with pytest.raises(NotFoundError, match="permission denied"):
|
|
345
|
+
load_snippet(tmp_path, "test/locked.txt")
|
|
346
|
+
finally:
|
|
347
|
+
f.chmod(0o644)
|
|
348
|
+
|
|
349
|
+
def test_invalid_utf8_raises(self, tmp_path):
|
|
350
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
351
|
+
snippet_dir.mkdir(parents=True)
|
|
352
|
+
(snippet_dir / "binary.txt").write_bytes(b"\xff\xfe invalid utf-8")
|
|
353
|
+
with pytest.raises(NotFoundError, match="not valid UTF-8"):
|
|
354
|
+
load_snippet(tmp_path, "test/binary.txt")
|
|
355
|
+
|
|
234
356
|
|
|
235
357
|
class TestResolveIncludes:
|
|
236
358
|
"""tests for snippet include resolution."""
|
|
@@ -259,6 +381,15 @@ class TestResolveIncludes:
|
|
|
259
381
|
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
260
382
|
resolve_includes(tmp_path, text)
|
|
261
383
|
|
|
384
|
+
def test_nonexistent_among_valid_raises(self, tmp_path):
|
|
385
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
386
|
+
snippet_dir.mkdir(parents=True)
|
|
387
|
+
(snippet_dir / "good.txt").write_text("ok")
|
|
388
|
+
|
|
389
|
+
text = "{{> snippet:test/good.txt}} {{> snippet:test/missing.txt}}"
|
|
390
|
+
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
391
|
+
resolve_includes(tmp_path, text)
|
|
392
|
+
|
|
262
393
|
def test_include_before_substitute(self, tmp_path):
|
|
263
394
|
"""Include resolution happens before placeholder substitution."""
|
|
264
395
|
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
@@ -325,3 +456,36 @@ class TestListSnippets:
|
|
|
325
456
|
_, first_line, is_local = custom_items[0]
|
|
326
457
|
assert is_local is True
|
|
327
458
|
assert first_line == "first line here"
|
|
459
|
+
|
|
460
|
+
def test_local_override_preserves_other_builtins(self, tmp_path):
|
|
461
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
|
|
462
|
+
snippet_dir.mkdir(parents=True)
|
|
463
|
+
(snippet_dir / "pyproject.toml").write_text("custom")
|
|
464
|
+
|
|
465
|
+
items = list_snippets(tmp_path)
|
|
466
|
+
lookup = {p: (f, loc) for p, f, loc in items}
|
|
467
|
+
assert lookup["python/pyproject.toml"][1] is True
|
|
468
|
+
assert "rust/Cargo.toml" in lookup
|
|
469
|
+
assert lookup["rust/Cargo.toml"][1] is False
|
|
470
|
+
|
|
471
|
+
def test_local_underscore_files_excluded(self, tmp_path):
|
|
472
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
473
|
+
snippet_dir.mkdir(parents=True)
|
|
474
|
+
(snippet_dir / "_hidden.txt").write_text("should not appear")
|
|
475
|
+
(snippet_dir / "visible.txt").write_text("should appear")
|
|
476
|
+
|
|
477
|
+
items = list_snippets(tmp_path)
|
|
478
|
+
paths = [p for p, _, _ in items]
|
|
479
|
+
assert "test/visible.txt" in paths
|
|
480
|
+
assert "test/_hidden.txt" not in paths
|
|
481
|
+
|
|
482
|
+
def test_list_snippets_skips_invalid_utf8(self, tmp_path):
|
|
483
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
484
|
+
snippet_dir.mkdir(parents=True)
|
|
485
|
+
(snippet_dir / "good.txt").write_text("good content")
|
|
486
|
+
(snippet_dir / "bad.txt").write_bytes(b"\xff\xfe not utf-8")
|
|
487
|
+
|
|
488
|
+
items = list_snippets(tmp_path)
|
|
489
|
+
local_paths = [p for p, _, is_local in items if is_local]
|
|
490
|
+
assert "test/good.txt" in local_paths
|
|
491
|
+
assert "test/bad.txt" not in local_paths
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{familiar_cli-0.4.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/implement-feature.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|