familiar-cli 0.4.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {familiar_cli-0.4.0/src/familiar_cli.egg-info → familiar_cli-0.5.0}/PKG-INFO +1 -1
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/pyproject.toml +1 -1
- familiar_cli-0.5.0/src/familiar/_plugins.py +44 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/agents.py +10 -13
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/cli.py +44 -11
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/lint.py +8 -23
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/render.py +60 -46
- {familiar_cli-0.4.0 → familiar_cli-0.5.0/src/familiar_cli.egg-info}/PKG-INFO +1 -1
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_agents.py +3 -3
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_cli.py +35 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_lint.py +4 -4
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/tests/test_render.py +89 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/LICENSE +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/readme.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/setup.cfg +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar/py.typed +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.4.0 → familiar_cli-0.5.0}/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,6 +10,8 @@ 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
17
|
from .agents import get_agents, get_agent
|
|
@@ -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,11 @@ 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
|
+
|
|
44
50
|
def find_repo_root(start: Path) -> Path:
|
|
45
51
|
"""Find the repository root by looking for .git directory.
|
|
46
52
|
|
|
@@ -54,6 +60,25 @@ def find_repo_root(start: Path) -> Path:
|
|
|
54
60
|
return cur
|
|
55
61
|
|
|
56
62
|
|
|
63
|
+
def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
64
|
+
"""Best-effort removal of a git worktree."""
|
|
65
|
+
try:
|
|
66
|
+
subprocess.run(
|
|
67
|
+
[
|
|
68
|
+
"git",
|
|
69
|
+
"-C",
|
|
70
|
+
str(repo_root),
|
|
71
|
+
"worktree",
|
|
72
|
+
"remove",
|
|
73
|
+
"--force",
|
|
74
|
+
str(worktree_path),
|
|
75
|
+
],
|
|
76
|
+
capture_output=True,
|
|
77
|
+
)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
57
82
|
def create_worktree(repo_root: Path) -> Path:
|
|
58
83
|
"""Create a temporary git worktree for the repository."""
|
|
59
84
|
# Ensure it's a git repo
|
|
@@ -98,7 +123,7 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
|
98
123
|
try:
|
|
99
124
|
agent = get_agent(agent_name)
|
|
100
125
|
except KeyError as e:
|
|
101
|
-
raise CliError(str(e), hint=
|
|
126
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
102
127
|
(repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
|
|
103
128
|
|
|
104
129
|
|
|
@@ -106,7 +131,7 @@ def write_skill(
|
|
|
106
131
|
repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
|
|
107
132
|
) -> Path:
|
|
108
133
|
"""Write a reusable skill file for supported agents."""
|
|
109
|
-
if not
|
|
134
|
+
if not _VALID_NAME.match(skill_name):
|
|
110
135
|
raise CliError(
|
|
111
136
|
f"invalid skill name: {skill_name}",
|
|
112
137
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
@@ -115,7 +140,7 @@ def write_skill(
|
|
|
115
140
|
try:
|
|
116
141
|
agent = get_agent(agent_name)
|
|
117
142
|
except KeyError as e:
|
|
118
|
-
raise CliError(str(e), hint=
|
|
143
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
119
144
|
|
|
120
145
|
if not agent.supports_skills():
|
|
121
146
|
raise CliError(f"agent does not support skills: {agent_name}")
|
|
@@ -143,7 +168,7 @@ def write_subagent(
|
|
|
143
168
|
system: str,
|
|
144
169
|
) -> Path:
|
|
145
170
|
"""Write a reusable subagent file for supported agents."""
|
|
146
|
-
if not
|
|
171
|
+
if not _VALID_NAME.match(subagent_name):
|
|
147
172
|
raise CliError(
|
|
148
173
|
f"invalid subagent name: {subagent_name}",
|
|
149
174
|
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
@@ -152,7 +177,7 @@ def write_subagent(
|
|
|
152
177
|
try:
|
|
153
178
|
agent = get_agent(agent_name)
|
|
154
179
|
except KeyError as e:
|
|
155
|
-
raise CliError(str(e), hint=
|
|
180
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
156
181
|
|
|
157
182
|
if not agent.supports_subagents():
|
|
158
183
|
raise CliError(f"agent does not support subagents: {agent_name}")
|
|
@@ -192,7 +217,7 @@ def run_agent(
|
|
|
192
217
|
try:
|
|
193
218
|
agent = get_agent(agent_name)
|
|
194
219
|
except KeyError as e:
|
|
195
|
-
raise CliError(str(e), hint=
|
|
220
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
196
221
|
try:
|
|
197
222
|
return agent.run(repo_root, prompt, headless, auto=auto)
|
|
198
223
|
except FileNotFoundError:
|
|
@@ -258,8 +283,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
258
283
|
instr_file = repo_root / agent.output_file
|
|
259
284
|
if instr_file.exists():
|
|
260
285
|
shutil.copy2(instr_file, target_dir / agent.output_file)
|
|
261
|
-
except (OSError, KeyError):
|
|
262
|
-
|
|
286
|
+
except (OSError, KeyError) as exc:
|
|
287
|
+
warnings.warn(
|
|
288
|
+
f"failed to copy instruction file into worktree: {exc}",
|
|
289
|
+
stacklevel=2,
|
|
290
|
+
)
|
|
263
291
|
|
|
264
292
|
try:
|
|
265
293
|
rc = run_agent(
|
|
@@ -271,7 +299,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
271
299
|
return rc
|
|
272
300
|
except Exception:
|
|
273
301
|
if args.worktree:
|
|
274
|
-
|
|
302
|
+
_remove_worktree(repo_root, target_dir)
|
|
275
303
|
raise
|
|
276
304
|
|
|
277
305
|
|
|
@@ -357,6 +385,11 @@ def main() -> None:
|
|
|
357
385
|
" familiar lint # validate prompts\n",
|
|
358
386
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
359
387
|
)
|
|
388
|
+
parser.add_argument(
|
|
389
|
+
"--version",
|
|
390
|
+
action="version",
|
|
391
|
+
version=f"%(prog)s {version('familiar-cli')}",
|
|
392
|
+
)
|
|
360
393
|
parser.add_argument(
|
|
361
394
|
"--debug", action="store_true", help="show full traceback on error"
|
|
362
395
|
)
|
|
@@ -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(
|
|
@@ -118,71 +118,85 @@ def _walk_traversable(root: Traversable, prefix: str = "") -> list[tuple[str, st
|
|
|
118
118
|
return items
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
121
|
+
def _list_resources(
|
|
122
|
+
repo_root: Path,
|
|
123
|
+
pkg: str,
|
|
124
|
+
local_subdir: str,
|
|
125
|
+
*,
|
|
126
|
+
recursive: bool = False,
|
|
127
|
+
suffix: str = "",
|
|
128
|
+
) -> list[tuple[str, str, bool]]:
|
|
129
|
+
"""List resources from package data and local overrides.
|
|
130
|
+
|
|
131
|
+
Items from local ``.familiar/{local_subdir}/`` override package builtins.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
repo_root: Repository root path.
|
|
135
|
+
pkg: Package name to scan for builtins.
|
|
136
|
+
local_subdir: Subdirectory name under ``.familiar/`` for local overrides.
|
|
137
|
+
recursive: If True, scan recursively (for nested resources like snippets).
|
|
138
|
+
suffix: File suffix filter (e.g. ``".md"``). Empty string matches all files.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
Sorted list of ``(key, first_line, is_local)`` tuples.
|
|
125
142
|
"""
|
|
126
143
|
items: dict[str, tuple[str, bool]] = {}
|
|
127
144
|
|
|
128
|
-
# built-
|
|
145
|
+
# built-in resources from package data
|
|
129
146
|
try:
|
|
130
|
-
pkg_root = resources.files(
|
|
131
|
-
|
|
132
|
-
|
|
147
|
+
pkg_root = resources.files(pkg)
|
|
148
|
+
if recursive:
|
|
149
|
+
for rel, first_line in _walk_traversable(pkg_root):
|
|
150
|
+
items[rel] = (first_line, False)
|
|
151
|
+
else:
|
|
152
|
+
for item in pkg_root.iterdir():
|
|
153
|
+
if suffix and not item.name.endswith(suffix):
|
|
154
|
+
continue
|
|
155
|
+
if item.name.startswith("_"):
|
|
156
|
+
continue
|
|
157
|
+
key = item.name[: -len(suffix)] if suffix else item.name
|
|
158
|
+
content = item.read_text(encoding="utf-8")
|
|
159
|
+
first_line = content.split("\n", 1)[0].strip()
|
|
160
|
+
items[key] = (first_line, False)
|
|
133
161
|
except (FileNotFoundError, TypeError, ModuleNotFoundError):
|
|
134
162
|
pass
|
|
135
163
|
|
|
136
164
|
# local overrides
|
|
137
|
-
local_dir = repo_root / ".familiar" /
|
|
165
|
+
local_dir = repo_root / ".familiar" / local_subdir
|
|
138
166
|
if local_dir.is_dir():
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
files = (
|
|
168
|
+
sorted(local_dir.rglob("*")) if recursive else local_dir.glob(f"*{suffix}")
|
|
169
|
+
)
|
|
170
|
+
for f in files:
|
|
171
|
+
if not f.is_file() or f.name.startswith("_"):
|
|
172
|
+
continue
|
|
173
|
+
key = str(f.relative_to(local_dir)) if recursive else f.stem
|
|
174
|
+
content = f.read_text(encoding="utf-8")
|
|
175
|
+
first_line = content.split("\n", 1)[0].strip()
|
|
176
|
+
items[key] = (first_line, True)
|
|
145
177
|
|
|
146
178
|
return [
|
|
147
|
-
(
|
|
148
|
-
for
|
|
179
|
+
(key, first_line, is_local)
|
|
180
|
+
for key, (first_line, is_local) in sorted(items.items())
|
|
149
181
|
]
|
|
150
182
|
|
|
151
183
|
|
|
152
|
-
def
|
|
153
|
-
"""List available
|
|
184
|
+
def list_snippets(repo_root: Path) -> list[tuple[str, str, bool]]:
|
|
185
|
+
"""List available snippets.
|
|
154
186
|
|
|
155
|
-
Returns list of (
|
|
187
|
+
Returns list of (path, first_line, is_local) tuples, sorted by path.
|
|
156
188
|
"""
|
|
157
|
-
|
|
189
|
+
return _list_resources(
|
|
190
|
+
repo_root, "familiar.data.snippets", "snippets", recursive=True
|
|
191
|
+
)
|
|
158
192
|
|
|
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
193
|
|
|
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)
|
|
194
|
+
def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
|
|
195
|
+
"""List available conjurings or invocations.
|
|
181
196
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
]
|
|
197
|
+
Returns list of (name, first_line, is_local) tuples, sorted by name.
|
|
198
|
+
"""
|
|
199
|
+
return _list_resources(repo_root, f"familiar.data.{kind}", kind, suffix=".md")
|
|
186
200
|
|
|
187
201
|
|
|
188
202
|
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
|
|
@@ -13,6 +13,7 @@ from familiar.cli import (
|
|
|
13
13
|
write_instruction,
|
|
14
14
|
parse_kv,
|
|
15
15
|
run_agent,
|
|
16
|
+
main,
|
|
16
17
|
cmd_conjure,
|
|
17
18
|
cmd_invoke,
|
|
18
19
|
cmd_list,
|
|
@@ -21,6 +22,19 @@ from familiar.cli import (
|
|
|
21
22
|
)
|
|
22
23
|
|
|
23
24
|
|
|
25
|
+
class TestVersion:
|
|
26
|
+
"""tests for --version flag."""
|
|
27
|
+
|
|
28
|
+
def test_version_prints_and_exits(self, capsys):
|
|
29
|
+
with pytest.raises(SystemExit, match="0"):
|
|
30
|
+
with patch("sys.argv", ["familiar", "--version"]):
|
|
31
|
+
main()
|
|
32
|
+
captured = capsys.readouterr()
|
|
33
|
+
assert "familiar" in captured.out
|
|
34
|
+
# version string should look like a semver
|
|
35
|
+
assert any(c.isdigit() for c in captured.out)
|
|
36
|
+
|
|
37
|
+
|
|
24
38
|
class TestCreateWorktree:
|
|
25
39
|
"""tests for git worktree creation."""
|
|
26
40
|
|
|
@@ -329,6 +343,27 @@ class TestCmdInvoke:
|
|
|
329
343
|
# Check that instruction file was copied
|
|
330
344
|
mock_copy.assert_called_once()
|
|
331
345
|
|
|
346
|
+
def test_invoke_worktree_cleaned_up_on_error(self, tmp_path):
|
|
347
|
+
with patch("familiar.cli.create_worktree", return_value=tmp_path / "wt"):
|
|
348
|
+
with patch("familiar.cli.run_agent", side_effect=RuntimeError("boom")):
|
|
349
|
+
with patch("familiar.cli._remove_worktree") as mock_remove:
|
|
350
|
+
args = argparse.Namespace(
|
|
351
|
+
agent="claude",
|
|
352
|
+
invocation="explain",
|
|
353
|
+
into=str(tmp_path),
|
|
354
|
+
headless=True,
|
|
355
|
+
auto=False,
|
|
356
|
+
dry_run=False,
|
|
357
|
+
kv=None,
|
|
358
|
+
inv_args=[],
|
|
359
|
+
worktree=True,
|
|
360
|
+
save_skill=False,
|
|
361
|
+
skill_name=None,
|
|
362
|
+
)
|
|
363
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
364
|
+
cmd_invoke(args)
|
|
365
|
+
mock_remove.assert_called_once()
|
|
366
|
+
|
|
332
367
|
def test_invoke_save_skill_claude(self, tmp_path):
|
|
333
368
|
with patch("familiar.cli.run_agent") as mock_run:
|
|
334
369
|
args = argparse.Namespace(
|
|
@@ -225,7 +225,7 @@ class TestLinterPlugins:
|
|
|
225
225
|
mock_ep.name = "invalid"
|
|
226
226
|
mock_ep.load.return_value = "not a function"
|
|
227
227
|
|
|
228
|
-
with patch("familiar.
|
|
228
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
229
229
|
with pytest.warns(UserWarning, match="not callable"):
|
|
230
230
|
linters = load_linters("conjurings")
|
|
231
231
|
assert len(linters) == 0
|
|
@@ -236,7 +236,7 @@ class TestLinterPlugins:
|
|
|
236
236
|
mock_ep.name = "broken"
|
|
237
237
|
mock_ep.load.side_effect = ImportError("module not found")
|
|
238
238
|
|
|
239
|
-
with patch("familiar.
|
|
239
|
+
with patch("familiar._plugins.entry_points", return_value=[mock_ep]):
|
|
240
240
|
with pytest.warns(UserWarning, match="failed to load"):
|
|
241
241
|
linters = load_linters("conjurings")
|
|
242
242
|
assert len(linters) == 0
|
|
@@ -258,7 +258,7 @@ class TestLinterPlugins:
|
|
|
258
258
|
templates.mkdir(parents=True)
|
|
259
259
|
(templates / "mytemplate.md").write_text("# test")
|
|
260
260
|
|
|
261
|
-
with patch("familiar.
|
|
261
|
+
with patch("familiar._plugins.entry_points") as mock_entry_points:
|
|
262
262
|
# Return our mock for templates, empty for invocations
|
|
263
263
|
def ep_side_effect(group):
|
|
264
264
|
if group == "familiar.linters.conjurings":
|
|
@@ -287,7 +287,7 @@ class TestLinterPlugins:
|
|
|
287
287
|
templates.mkdir(parents=True)
|
|
288
288
|
(templates / "test.md").write_text("# test")
|
|
289
289
|
|
|
290
|
-
with patch("familiar.
|
|
290
|
+
with patch("familiar._plugins.entry_points") as mock_entry_points:
|
|
291
291
|
|
|
292
292
|
def ep_side_effect(group):
|
|
293
293
|
if group == "familiar.linters.conjurings":
|
|
@@ -121,6 +121,34 @@ class TestComposeSystem:
|
|
|
121
121
|
# verify order: core, then conjurings in order
|
|
122
122
|
assert system == "CORE\n\nFIRST\n\nSECOND"
|
|
123
123
|
|
|
124
|
+
def test_compose_core_only(self, tmp_path):
|
|
125
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
126
|
+
templates.mkdir(parents=True)
|
|
127
|
+
(templates / "core.md").write_text("CORE")
|
|
128
|
+
|
|
129
|
+
system = compose_system(tmp_path, [])
|
|
130
|
+
assert system == "CORE"
|
|
131
|
+
|
|
132
|
+
def test_compose_multiple_conjurings(self, tmp_path):
|
|
133
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
134
|
+
templates.mkdir(parents=True)
|
|
135
|
+
(templates / "core.md").write_text("CORE")
|
|
136
|
+
(templates / "a.md").write_text("A")
|
|
137
|
+
(templates / "b.md").write_text("B")
|
|
138
|
+
(templates / "c.md").write_text("C")
|
|
139
|
+
|
|
140
|
+
system = compose_system(tmp_path, ["a", "b", "c"])
|
|
141
|
+
assert system == "CORE\n\nA\n\nB\n\nC"
|
|
142
|
+
|
|
143
|
+
def test_compose_strips_whitespace(self, tmp_path):
|
|
144
|
+
templates = tmp_path / ".familiar" / "conjurings"
|
|
145
|
+
templates.mkdir(parents=True)
|
|
146
|
+
(templates / "core.md").write_text(" CORE \n")
|
|
147
|
+
(templates / "pad.md").write_text("\n PAD \n\n")
|
|
148
|
+
|
|
149
|
+
system = compose_system(tmp_path, ["pad"])
|
|
150
|
+
assert system == "CORE\n\nPAD"
|
|
151
|
+
|
|
124
152
|
def test_compose_missing_profile_raises(self, tmp_path):
|
|
125
153
|
with pytest.raises(NotFoundError, match="unknown conjuring"):
|
|
126
154
|
compose_system(tmp_path, ["nonexistent"])
|
|
@@ -137,6 +165,36 @@ class TestRenderInvocation:
|
|
|
137
165
|
result = render_invocation(tmp_path, "greet", ["world"], {"style": "friendly"})
|
|
138
166
|
assert result == "hello world, friendly"
|
|
139
167
|
|
|
168
|
+
def test_render_with_snippet_and_args(self, tmp_path):
|
|
169
|
+
snippets = tmp_path / ".familiar" / "snippets" / "test"
|
|
170
|
+
snippets.mkdir(parents=True)
|
|
171
|
+
(snippets / "header.txt").write_text("== $1 ==")
|
|
172
|
+
|
|
173
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
174
|
+
invocations.mkdir(parents=True)
|
|
175
|
+
(invocations / "demo.md").write_text(
|
|
176
|
+
"{{> snippet:test/header.txt}}\nbody: {{mode}}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result = render_invocation(tmp_path, "demo", ["title"], {"mode": "fast"})
|
|
180
|
+
assert result == "== title ==\nbody: fast"
|
|
181
|
+
|
|
182
|
+
def test_render_no_args(self, tmp_path):
|
|
183
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
184
|
+
invocations.mkdir(parents=True)
|
|
185
|
+
(invocations / "plain.md").write_text("no placeholders here")
|
|
186
|
+
|
|
187
|
+
result = render_invocation(tmp_path, "plain", [], {})
|
|
188
|
+
assert result == "no placeholders here"
|
|
189
|
+
|
|
190
|
+
def test_render_arguments_placeholder(self, tmp_path):
|
|
191
|
+
invocations = tmp_path / ".familiar" / "invocations"
|
|
192
|
+
invocations.mkdir(parents=True)
|
|
193
|
+
(invocations / "all.md").write_text("run $ARGUMENTS")
|
|
194
|
+
|
|
195
|
+
result = render_invocation(tmp_path, "all", ["a", "b", "c"], {})
|
|
196
|
+
assert result == "run a b c"
|
|
197
|
+
|
|
140
198
|
def test_render_missing_invocation_raises(self, tmp_path):
|
|
141
199
|
with pytest.raises(NotFoundError, match="unknown invocation"):
|
|
142
200
|
render_invocation(tmp_path, "nonexistent", [], {})
|
|
@@ -259,6 +317,15 @@ class TestResolveIncludes:
|
|
|
259
317
|
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
260
318
|
resolve_includes(tmp_path, text)
|
|
261
319
|
|
|
320
|
+
def test_nonexistent_among_valid_raises(self, tmp_path):
|
|
321
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
322
|
+
snippet_dir.mkdir(parents=True)
|
|
323
|
+
(snippet_dir / "good.txt").write_text("ok")
|
|
324
|
+
|
|
325
|
+
text = "{{> snippet:test/good.txt}} {{> snippet:test/missing.txt}}"
|
|
326
|
+
with pytest.raises(NotFoundError, match="unknown snippet"):
|
|
327
|
+
resolve_includes(tmp_path, text)
|
|
328
|
+
|
|
262
329
|
def test_include_before_substitute(self, tmp_path):
|
|
263
330
|
"""Include resolution happens before placeholder substitution."""
|
|
264
331
|
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
@@ -325,3 +392,25 @@ class TestListSnippets:
|
|
|
325
392
|
_, first_line, is_local = custom_items[0]
|
|
326
393
|
assert is_local is True
|
|
327
394
|
assert first_line == "first line here"
|
|
395
|
+
|
|
396
|
+
def test_local_override_preserves_other_builtins(self, tmp_path):
|
|
397
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "python"
|
|
398
|
+
snippet_dir.mkdir(parents=True)
|
|
399
|
+
(snippet_dir / "pyproject.toml").write_text("custom")
|
|
400
|
+
|
|
401
|
+
items = list_snippets(tmp_path)
|
|
402
|
+
lookup = {p: (f, loc) for p, f, loc in items}
|
|
403
|
+
assert lookup["python/pyproject.toml"][1] is True
|
|
404
|
+
assert "rust/Cargo.toml" in lookup
|
|
405
|
+
assert lookup["rust/Cargo.toml"][1] is False
|
|
406
|
+
|
|
407
|
+
def test_local_underscore_files_excluded(self, tmp_path):
|
|
408
|
+
snippet_dir = tmp_path / ".familiar" / "snippets" / "test"
|
|
409
|
+
snippet_dir.mkdir(parents=True)
|
|
410
|
+
(snippet_dir / "_hidden.txt").write_text("should not appear")
|
|
411
|
+
(snippet_dir / "visible.txt").write_text("should appear")
|
|
412
|
+
|
|
413
|
+
items = list_snippets(tmp_path)
|
|
414
|
+
paths = [p for p, _, _ in items]
|
|
415
|
+
assert "test/visible.txt" in paths
|
|
416
|
+
assert "test/_hidden.txt" not in 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.0}/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
|