familiar-cli 0.5.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.5.0/src/familiar_cli.egg-info → familiar_cli-0.5.1}/PKG-INFO +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/pyproject.toml +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/cli.py +37 -23
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/lint.py +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/render.py +24 -5
- {familiar_cli-0.5.0 → familiar_cli-0.5.1/src/familiar_cli.egg-info}/PKG-INFO +1 -1
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_cli.py +91 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_lint.py +44 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_render.py +75 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/LICENSE +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/readme.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/setup.cfg +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/_plugins.py +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/agents.py +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar/py.typed +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_agents.py +0 -0
- {familiar_cli-0.5.0 → familiar_cli-0.5.1}/tests/test_integration.py +0 -0
|
@@ -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,
|
|
@@ -47,6 +47,27 @@ def _agent_hint() -> str:
|
|
|
47
47
|
return f"valid agents: {', '.join(get_agents().keys())}"
|
|
48
48
|
|
|
49
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
|
+
|
|
50
71
|
def find_repo_root(start: Path) -> Path:
|
|
51
72
|
"""Find the repository root by looking for .git directory.
|
|
52
73
|
|
|
@@ -63,7 +84,7 @@ def find_repo_root(start: Path) -> Path:
|
|
|
63
84
|
def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
64
85
|
"""Best-effort removal of a git worktree."""
|
|
65
86
|
try:
|
|
66
|
-
subprocess.run(
|
|
87
|
+
result = subprocess.run(
|
|
67
88
|
[
|
|
68
89
|
"git",
|
|
69
90
|
"-C",
|
|
@@ -75,6 +96,12 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
|
75
96
|
],
|
|
76
97
|
capture_output=True,
|
|
77
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
|
+
)
|
|
78
105
|
except FileNotFoundError:
|
|
79
106
|
pass
|
|
80
107
|
|
|
@@ -120,11 +147,9 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
120
147
|
|
|
121
148
|
|
|
122
149
|
def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
127
|
-
(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")
|
|
128
153
|
|
|
129
154
|
|
|
130
155
|
def write_skill(
|
|
@@ -137,16 +162,12 @@ def write_skill(
|
|
|
137
162
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
138
163
|
)
|
|
139
164
|
|
|
140
|
-
|
|
141
|
-
agent = get_agent(agent_name)
|
|
142
|
-
except KeyError as e:
|
|
143
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
165
|
+
agent = _resolve_agent(agent_name)
|
|
144
166
|
|
|
145
167
|
if not agent.supports_skills():
|
|
146
168
|
raise CliError(f"agent does not support skills: {agent_name}")
|
|
147
169
|
|
|
148
170
|
skill_path = agent.skill_path(repo_root, skill_name)
|
|
149
|
-
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
171
|
content = (
|
|
151
172
|
f"# {skill_name}\n\n"
|
|
152
173
|
"use this skill when this recurring task appears in this repository.\n\n"
|
|
@@ -156,7 +177,7 @@ def write_skill(
|
|
|
156
177
|
"## instructions\n\n"
|
|
157
178
|
f"{prompt.strip()}\n"
|
|
158
179
|
)
|
|
159
|
-
skill_path
|
|
180
|
+
_write_file(skill_path, content, label="skill")
|
|
160
181
|
return skill_path
|
|
161
182
|
|
|
162
183
|
|
|
@@ -174,16 +195,12 @@ def write_subagent(
|
|
|
174
195
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
175
196
|
)
|
|
176
197
|
|
|
177
|
-
|
|
178
|
-
agent = get_agent(agent_name)
|
|
179
|
-
except KeyError as e:
|
|
180
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
198
|
+
agent = _resolve_agent(agent_name)
|
|
181
199
|
|
|
182
200
|
if not agent.supports_subagents():
|
|
183
201
|
raise CliError(f"agent does not support subagents: {agent_name}")
|
|
184
202
|
|
|
185
203
|
subagent_path = agent.subagent_path(repo_root, subagent_name)
|
|
186
|
-
subagent_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
204
|
profile = ", ".join(conjurings)
|
|
188
205
|
content = (
|
|
189
206
|
f"# {subagent_name}\n\n"
|
|
@@ -194,7 +211,7 @@ def write_subagent(
|
|
|
194
211
|
"## instructions\n\n"
|
|
195
212
|
f"{system.strip()}\n"
|
|
196
213
|
)
|
|
197
|
-
subagent_path
|
|
214
|
+
_write_file(subagent_path, content, label="subagent")
|
|
198
215
|
return subagent_path
|
|
199
216
|
|
|
200
217
|
|
|
@@ -214,10 +231,7 @@ def parse_kv(pairs: list[str]) -> dict[str, str]:
|
|
|
214
231
|
def run_agent(
|
|
215
232
|
repo_root: Path, agent_name: str, prompt: str, headless: bool, auto: bool = False
|
|
216
233
|
) -> int:
|
|
217
|
-
|
|
218
|
-
agent = get_agent(agent_name)
|
|
219
|
-
except KeyError as e:
|
|
220
|
-
raise CliError(str(e), hint=_agent_hint())
|
|
234
|
+
agent = _resolve_agent(agent_name)
|
|
221
235
|
try:
|
|
222
236
|
return agent.run(repo_root, prompt, headless, auto=auto)
|
|
223
237
|
except FileNotFoundError:
|
|
@@ -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):
|
|
@@ -155,7 +168,10 @@ def _list_resources(
|
|
|
155
168
|
if item.name.startswith("_"):
|
|
156
169
|
continue
|
|
157
170
|
key = item.name[: -len(suffix)] if suffix else item.name
|
|
158
|
-
|
|
171
|
+
try:
|
|
172
|
+
content = item.read_text(encoding="utf-8")
|
|
173
|
+
except (UnicodeDecodeError, PermissionError):
|
|
174
|
+
continue
|
|
159
175
|
first_line = content.split("\n", 1)[0].strip()
|
|
160
176
|
items[key] = (first_line, False)
|
|
161
177
|
except (FileNotFoundError, TypeError, ModuleNotFoundError):
|
|
@@ -171,7 +187,10 @@ def _list_resources(
|
|
|
171
187
|
if not f.is_file() or f.name.startswith("_"):
|
|
172
188
|
continue
|
|
173
189
|
key = str(f.relative_to(local_dir)) if recursive else f.stem
|
|
174
|
-
|
|
190
|
+
try:
|
|
191
|
+
content = f.read_text(encoding="utf-8")
|
|
192
|
+
except (UnicodeDecodeError, PermissionError):
|
|
193
|
+
continue
|
|
175
194
|
first_line = content.split("\n", 1)[0].strip()
|
|
176
195
|
items[key] = (first_line, True)
|
|
177
196
|
|
|
@@ -8,9 +8,14 @@ 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,
|
|
16
21
|
main,
|
|
@@ -84,6 +89,50 @@ class TestFindRepoRoot:
|
|
|
84
89
|
assert result == subdir.resolve()
|
|
85
90
|
|
|
86
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
|
+
|
|
87
136
|
class TestWriteInstruction:
|
|
88
137
|
"""tests for writing instruction files."""
|
|
89
138
|
|
|
@@ -100,6 +149,48 @@ class TestWriteInstruction:
|
|
|
100
149
|
assert (tmp_path / "CLAUDE.md").read_text() == "content\n"
|
|
101
150
|
|
|
102
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
|
+
|
|
103
194
|
class TestParseKv:
|
|
104
195
|
"""tests for key-value parsing."""
|
|
105
196
|
|
|
@@ -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:
|
|
@@ -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."""
|
|
@@ -262,6 +281,32 @@ class TestListItems:
|
|
|
262
281
|
items = list_items(tmp_path, "conjurings")
|
|
263
282
|
assert len(items) > 0
|
|
264
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
|
+
|
|
265
310
|
|
|
266
311
|
class TestLoadSnippet:
|
|
267
312
|
"""tests for loading snippets."""
|
|
@@ -289,6 +334,25 @@ class TestLoadSnippet:
|
|
|
289
334
|
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
290
335
|
load_snippet(tmp_path, "nonexistent/file.txt")
|
|
291
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
|
+
|
|
292
356
|
|
|
293
357
|
class TestResolveIncludes:
|
|
294
358
|
"""tests for snippet include resolution."""
|
|
@@ -414,3 +478,14 @@ class TestListSnippets:
|
|
|
414
478
|
paths = [p for p, _, _ in items]
|
|
415
479
|
assert "test/visible.txt" in paths
|
|
416
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
|
|
File without changes
|
|
File without changes
|
{familiar_cli-0.5.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
|
|
File without changes
|
|
File without changes
|