familiar-cli 0.3.1__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.3.1/src/familiar_cli.egg-info → familiar_cli-0.5.0}/PKG-INFO +15 -1
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/pyproject.toml +1 -1
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/readme.md +14 -0
- familiar_cli-0.5.0/src/familiar/_plugins.py +44 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/agents.py +38 -13
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/cli.py +146 -5
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/lint.py +8 -23
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/render.py +60 -46
- {familiar_cli-0.3.1 → familiar_cli-0.5.0/src/familiar_cli.egg-info}/PKG-INFO +15 -1
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/SOURCES.txt +1 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_agents.py +39 -3
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_cli.py +159 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_lint.py +4 -4
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_render.py +89 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/LICENSE +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/setup.cfg +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/__init__.py +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar/py.typed +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/requires.txt +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
- {familiar_cli-0.3.1 → familiar_cli-0.5.0}/tests/test_integration.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: compose and invoke ai agent prompts from reusable templates
|
|
5
5
|
Author-email: cyberwitchery lab <contact@cyberwitchery.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,6 +66,20 @@ conjure conjurings to create system instructions for an agent:
|
|
|
66
66
|
familiar conjure codex rust infra sec
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
save composed conjurings as a reusable subagent:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
familiar conjure claude python sec --save-subagent
|
|
73
|
+
familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
save an invocation as a reusable skill:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
familiar invoke claude code-review --save-skill
|
|
80
|
+
familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
|
|
81
|
+
```
|
|
82
|
+
|
|
69
83
|
invoke an action prompt:
|
|
70
84
|
|
|
71
85
|
```bash
|
|
@@ -37,6 +37,20 @@ conjure conjurings to create system instructions for an agent:
|
|
|
37
37
|
familiar conjure codex rust infra sec
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
save composed conjurings as a reusable subagent:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
familiar conjure claude python sec --save-subagent
|
|
44
|
+
familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
save an invocation as a reusable skill:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
familiar invoke claude code-review --save-skill
|
|
51
|
+
familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
|
|
52
|
+
```
|
|
53
|
+
|
|
40
54
|
invoke an action prompt:
|
|
41
55
|
|
|
42
56
|
```bash
|
|
@@ -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,15 +5,20 @@ 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."""
|
|
14
15
|
|
|
15
16
|
name: str
|
|
16
17
|
output_file: str
|
|
18
|
+
skill_dir: str | None = None
|
|
19
|
+
skill_file: str = "SKILL.md"
|
|
20
|
+
subagent_dir: str | None = None
|
|
21
|
+
subagent_file: str = "AGENT.md"
|
|
17
22
|
|
|
18
23
|
@abstractmethod
|
|
19
24
|
def run(
|
|
@@ -21,10 +26,32 @@ class Agent(ABC):
|
|
|
21
26
|
) -> int:
|
|
22
27
|
"""Run the agent with the given prompt."""
|
|
23
28
|
|
|
29
|
+
def supports_skills(self) -> bool:
|
|
30
|
+
"""Whether this agent supports reusable skill files."""
|
|
31
|
+
return self.skill_dir is not None
|
|
32
|
+
|
|
33
|
+
def skill_path(self, repo_root: Path, skill_name: str) -> Path:
|
|
34
|
+
"""Build the skill path for a given skill name."""
|
|
35
|
+
if self.skill_dir is None:
|
|
36
|
+
raise ValueError(f"agent does not support skills: {self.name}")
|
|
37
|
+
return repo_root / self.skill_dir / skill_name / self.skill_file
|
|
38
|
+
|
|
39
|
+
def supports_subagents(self) -> bool:
|
|
40
|
+
"""Whether this agent supports reusable subagent files."""
|
|
41
|
+
return self.subagent_dir is not None
|
|
42
|
+
|
|
43
|
+
def subagent_path(self, repo_root: Path, subagent_name: str) -> Path:
|
|
44
|
+
"""Build the subagent path for a given subagent name."""
|
|
45
|
+
if self.subagent_dir is None:
|
|
46
|
+
raise ValueError(f"agent does not support subagents: {self.name}")
|
|
47
|
+
return repo_root / self.subagent_dir / subagent_name / self.subagent_file
|
|
48
|
+
|
|
24
49
|
|
|
25
50
|
class CodexAgent(Agent):
|
|
26
51
|
name = "codex"
|
|
27
52
|
output_file = "AGENTS.md"
|
|
53
|
+
skill_dir = ".codex/skills"
|
|
54
|
+
subagent_dir = ".codex/subagents"
|
|
28
55
|
|
|
29
56
|
def run(
|
|
30
57
|
self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
|
|
@@ -47,6 +74,8 @@ class CodexAgent(Agent):
|
|
|
47
74
|
class ClaudeAgent(Agent):
|
|
48
75
|
name = "claude"
|
|
49
76
|
output_file = "CLAUDE.md"
|
|
77
|
+
skill_dir = ".claude/skills"
|
|
78
|
+
subagent_dir = ".claude/subagents"
|
|
50
79
|
|
|
51
80
|
def run(
|
|
52
81
|
self, repo_root: Path, prompt: str, headless: bool, auto: bool = False
|
|
@@ -68,26 +97,22 @@ def load_agents() -> dict[str, Agent]:
|
|
|
68
97
|
Dictionary mapping agent names to Agent instances.
|
|
69
98
|
Plugins that fail to load are skipped with a warning.
|
|
70
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
|
+
)
|
|
71
106
|
agents: dict[str, Agent] = {}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
for ep in eps:
|
|
107
|
+
for cls in plugins:
|
|
75
108
|
try:
|
|
76
|
-
cls = ep.load()
|
|
77
|
-
if not (isinstance(cls, type) and issubclass(cls, Agent)):
|
|
78
|
-
warnings.warn(
|
|
79
|
-
f"agent plugin '{ep.name}': not a valid Agent subclass",
|
|
80
|
-
stacklevel=2,
|
|
81
|
-
)
|
|
82
|
-
continue
|
|
83
109
|
instance = cls()
|
|
84
110
|
agents[instance.name] = instance
|
|
85
111
|
except Exception as e:
|
|
86
112
|
warnings.warn(
|
|
87
|
-
f"failed to load agent plugin '{
|
|
113
|
+
f"failed to load agent plugin '{cls.__name__}': {e}",
|
|
88
114
|
stacklevel=2,
|
|
89
115
|
)
|
|
90
|
-
|
|
91
116
|
return agents
|
|
92
117
|
|
|
93
118
|
|
|
@@ -4,11 +4,14 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
|
+
import re
|
|
7
8
|
import shutil
|
|
8
9
|
import subprocess
|
|
9
10
|
import sys
|
|
10
11
|
import tempfile
|
|
11
12
|
import traceback
|
|
13
|
+
import warnings
|
|
14
|
+
from importlib.metadata import version
|
|
12
15
|
from pathlib import Path
|
|
13
16
|
|
|
14
17
|
from .agents import get_agents, get_agent
|
|
@@ -25,6 +28,7 @@ from .render import (
|
|
|
25
28
|
EXIT_SUCCESS = 0
|
|
26
29
|
EXIT_ERROR = 1 # general error (agent failed, etc.)
|
|
27
30
|
EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
|
|
31
|
+
_VALID_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
28
32
|
|
|
29
33
|
|
|
30
34
|
class CliError(Exception):
|
|
@@ -38,6 +42,11 @@ class CliError(Exception):
|
|
|
38
42
|
self.exit_code = exit_code
|
|
39
43
|
|
|
40
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
|
+
|
|
41
50
|
def find_repo_root(start: Path) -> Path:
|
|
42
51
|
"""Find the repository root by looking for .git directory.
|
|
43
52
|
|
|
@@ -51,6 +60,25 @@ def find_repo_root(start: Path) -> Path:
|
|
|
51
60
|
return cur
|
|
52
61
|
|
|
53
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
|
+
|
|
54
82
|
def create_worktree(repo_root: Path) -> Path:
|
|
55
83
|
"""Create a temporary git worktree for the repository."""
|
|
56
84
|
# Ensure it's a git repo
|
|
@@ -95,10 +123,81 @@ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
|
|
|
95
123
|
try:
|
|
96
124
|
agent = get_agent(agent_name)
|
|
97
125
|
except KeyError as e:
|
|
98
|
-
raise CliError(str(e), hint=
|
|
126
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
99
127
|
(repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
|
|
100
128
|
|
|
101
129
|
|
|
130
|
+
def write_skill(
|
|
131
|
+
repo_root: Path, agent_name: str, invocation: str, skill_name: str, prompt: str
|
|
132
|
+
) -> Path:
|
|
133
|
+
"""Write a reusable skill file for supported agents."""
|
|
134
|
+
if not _VALID_NAME.match(skill_name):
|
|
135
|
+
raise CliError(
|
|
136
|
+
f"invalid skill name: {skill_name}",
|
|
137
|
+
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
agent = get_agent(agent_name)
|
|
142
|
+
except KeyError as e:
|
|
143
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
144
|
+
|
|
145
|
+
if not agent.supports_skills():
|
|
146
|
+
raise CliError(f"agent does not support skills: {agent_name}")
|
|
147
|
+
|
|
148
|
+
skill_path = agent.skill_path(repo_root, skill_name)
|
|
149
|
+
skill_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
content = (
|
|
151
|
+
f"# {skill_name}\n\n"
|
|
152
|
+
"use this skill when this recurring task appears in this repository.\n\n"
|
|
153
|
+
"## generated from\n\n"
|
|
154
|
+
f"- invocation: {invocation}\n"
|
|
155
|
+
f"- agent: {agent_name}\n\n"
|
|
156
|
+
"## instructions\n\n"
|
|
157
|
+
f"{prompt.strip()}\n"
|
|
158
|
+
)
|
|
159
|
+
skill_path.write_text(content, encoding="utf-8")
|
|
160
|
+
return skill_path
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_subagent(
|
|
164
|
+
repo_root: Path,
|
|
165
|
+
agent_name: str,
|
|
166
|
+
conjurings: list[str],
|
|
167
|
+
subagent_name: str,
|
|
168
|
+
system: str,
|
|
169
|
+
) -> Path:
|
|
170
|
+
"""Write a reusable subagent file for supported agents."""
|
|
171
|
+
if not _VALID_NAME.match(subagent_name):
|
|
172
|
+
raise CliError(
|
|
173
|
+
f"invalid subagent name: {subagent_name}",
|
|
174
|
+
hint="use lowercase letters, numbers, underscore, or hyphen",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
agent = get_agent(agent_name)
|
|
179
|
+
except KeyError as e:
|
|
180
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
181
|
+
|
|
182
|
+
if not agent.supports_subagents():
|
|
183
|
+
raise CliError(f"agent does not support subagents: {agent_name}")
|
|
184
|
+
|
|
185
|
+
subagent_path = agent.subagent_path(repo_root, subagent_name)
|
|
186
|
+
subagent_path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
profile = ", ".join(conjurings)
|
|
188
|
+
content = (
|
|
189
|
+
f"# {subagent_name}\n\n"
|
|
190
|
+
"use this subagent when this repository needs this persistent behavior profile.\n\n"
|
|
191
|
+
"## generated from\n\n"
|
|
192
|
+
f"- conjurings: {profile}\n"
|
|
193
|
+
f"- agent: {agent_name}\n\n"
|
|
194
|
+
"## instructions\n\n"
|
|
195
|
+
f"{system.strip()}\n"
|
|
196
|
+
)
|
|
197
|
+
subagent_path.write_text(content, encoding="utf-8")
|
|
198
|
+
return subagent_path
|
|
199
|
+
|
|
200
|
+
|
|
102
201
|
def parse_kv(pairs: list[str]) -> dict[str, str]:
|
|
103
202
|
out: dict[str, str] = {}
|
|
104
203
|
for p in pairs:
|
|
@@ -118,7 +217,7 @@ def run_agent(
|
|
|
118
217
|
try:
|
|
119
218
|
agent = get_agent(agent_name)
|
|
120
219
|
except KeyError as e:
|
|
121
|
-
raise CliError(str(e), hint=
|
|
220
|
+
raise CliError(str(e), hint=_agent_hint())
|
|
122
221
|
try:
|
|
123
222
|
return agent.run(repo_root, prompt, headless, auto=auto)
|
|
124
223
|
except FileNotFoundError:
|
|
@@ -138,6 +237,14 @@ def cmd_conjure(args: argparse.Namespace) -> int:
|
|
|
138
237
|
hint="run 'familiar list conjurings' to see available options",
|
|
139
238
|
)
|
|
140
239
|
write_instruction(repo_root, args.agent, system)
|
|
240
|
+
if getattr(args, "save_subagent", False):
|
|
241
|
+
subagent_name = getattr(args, "subagent_name", None) or "_".join(
|
|
242
|
+
args.conjurings
|
|
243
|
+
)
|
|
244
|
+
subagent_path = write_subagent(
|
|
245
|
+
repo_root, args.agent, args.conjurings, subagent_name, system
|
|
246
|
+
)
|
|
247
|
+
print(f"wrote subagent: {subagent_path}")
|
|
141
248
|
print(f"wrote instructions for {args.agent}")
|
|
142
249
|
return EXIT_SUCCESS
|
|
143
250
|
|
|
@@ -157,6 +264,14 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
157
264
|
print(prompt)
|
|
158
265
|
return EXIT_SUCCESS
|
|
159
266
|
|
|
267
|
+
if getattr(args, "save_skill", False):
|
|
268
|
+
skill_name = getattr(args, "skill_name", None) or args.invocation
|
|
269
|
+
skill_path = write_skill(
|
|
270
|
+
repo_root, args.agent, args.invocation, skill_name, prompt
|
|
271
|
+
)
|
|
272
|
+
print(f"wrote skill: {skill_path}")
|
|
273
|
+
return EXIT_SUCCESS
|
|
274
|
+
|
|
160
275
|
target_dir = repo_root
|
|
161
276
|
if args.worktree:
|
|
162
277
|
target_dir = create_worktree(repo_root)
|
|
@@ -168,8 +283,11 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
168
283
|
instr_file = repo_root / agent.output_file
|
|
169
284
|
if instr_file.exists():
|
|
170
285
|
shutil.copy2(instr_file, target_dir / agent.output_file)
|
|
171
|
-
except (OSError, KeyError):
|
|
172
|
-
|
|
286
|
+
except (OSError, KeyError) as exc:
|
|
287
|
+
warnings.warn(
|
|
288
|
+
f"failed to copy instruction file into worktree: {exc}",
|
|
289
|
+
stacklevel=2,
|
|
290
|
+
)
|
|
173
291
|
|
|
174
292
|
try:
|
|
175
293
|
rc = run_agent(
|
|
@@ -181,7 +299,7 @@ def cmd_invoke(args: argparse.Namespace) -> int:
|
|
|
181
299
|
return rc
|
|
182
300
|
except Exception:
|
|
183
301
|
if args.worktree:
|
|
184
|
-
|
|
302
|
+
_remove_worktree(repo_root, target_dir)
|
|
185
303
|
raise
|
|
186
304
|
|
|
187
305
|
|
|
@@ -267,6 +385,11 @@ def main() -> None:
|
|
|
267
385
|
" familiar lint # validate prompts\n",
|
|
268
386
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
269
387
|
)
|
|
388
|
+
parser.add_argument(
|
|
389
|
+
"--version",
|
|
390
|
+
action="version",
|
|
391
|
+
version=f"%(prog)s {version('familiar-cli')}",
|
|
392
|
+
)
|
|
270
393
|
parser.add_argument(
|
|
271
394
|
"--debug", action="store_true", help="show full traceback on error"
|
|
272
395
|
)
|
|
@@ -285,6 +408,15 @@ def main() -> None:
|
|
|
285
408
|
"conjurings", nargs="+", help="conjuring names, e.g. rust infra sec"
|
|
286
409
|
)
|
|
287
410
|
conjure.add_argument("--into", help="target repo path (default: current directory)")
|
|
411
|
+
conjure.add_argument(
|
|
412
|
+
"--save-subagent",
|
|
413
|
+
action="store_true",
|
|
414
|
+
help="write composed conjurings as a reusable subagent",
|
|
415
|
+
)
|
|
416
|
+
conjure.add_argument(
|
|
417
|
+
"--subagent-name",
|
|
418
|
+
help="override subagent name (default: joined conjuring names)",
|
|
419
|
+
)
|
|
288
420
|
conjure.set_defaults(func=cmd_conjure)
|
|
289
421
|
|
|
290
422
|
invoke = sub.add_parser(
|
|
@@ -308,6 +440,15 @@ def main() -> None:
|
|
|
308
440
|
invoke.add_argument(
|
|
309
441
|
"--worktree", action="store_true", help="run in a separate git worktree"
|
|
310
442
|
)
|
|
443
|
+
invoke.add_argument(
|
|
444
|
+
"--save-skill",
|
|
445
|
+
action="store_true",
|
|
446
|
+
help="write rendered invocation as a skill and exit",
|
|
447
|
+
)
|
|
448
|
+
invoke.add_argument(
|
|
449
|
+
"--skill-name",
|
|
450
|
+
help="override skill name (default: invocation name)",
|
|
451
|
+
)
|
|
311
452
|
invoke.add_argument("--kv", nargs="*", help="named arguments as key=value pairs")
|
|
312
453
|
invoke.add_argument(
|
|
313
454
|
"inv_args", nargs="*", help="positional arguments for the invocation"
|
|
@@ -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:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: compose and invoke ai agent prompts from reusable templates
|
|
5
5
|
Author-email: cyberwitchery lab <contact@cyberwitchery.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -66,6 +66,20 @@ conjure conjurings to create system instructions for an agent:
|
|
|
66
66
|
familiar conjure codex rust infra sec
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
save composed conjurings as a reusable subagent:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
familiar conjure claude python sec --save-subagent
|
|
73
|
+
familiar conjure codex rust infra --save-subagent --subagent-name ship_ops
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
save an invocation as a reusable skill:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
familiar invoke claude code-review --save-skill
|
|
80
|
+
familiar invoke codex refactor src/foo.py --save-skill --skill-name cleanup_refactor
|
|
81
|
+
```
|
|
82
|
+
|
|
69
83
|
invoke an action prompt:
|
|
70
84
|
|
|
71
85
|
```bash
|
|
@@ -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
|
|
@@ -118,6 +118,24 @@ class TestCodexAgent:
|
|
|
118
118
|
agent = CodexAgent()
|
|
119
119
|
assert agent.output_file == "AGENTS.md"
|
|
120
120
|
|
|
121
|
+
def test_supports_skills(self):
|
|
122
|
+
agent = CodexAgent()
|
|
123
|
+
assert agent.supports_skills() is True
|
|
124
|
+
|
|
125
|
+
def test_skill_path(self, tmp_path):
|
|
126
|
+
agent = CodexAgent()
|
|
127
|
+
path = agent.skill_path(tmp_path, "refactor")
|
|
128
|
+
assert path == tmp_path / ".codex" / "skills" / "refactor" / "SKILL.md"
|
|
129
|
+
|
|
130
|
+
def test_supports_subagents(self):
|
|
131
|
+
agent = CodexAgent()
|
|
132
|
+
assert agent.supports_subagents() is True
|
|
133
|
+
|
|
134
|
+
def test_subagent_path(self, tmp_path):
|
|
135
|
+
agent = CodexAgent()
|
|
136
|
+
path = agent.subagent_path(tmp_path, "python_sec")
|
|
137
|
+
assert path == tmp_path / ".codex" / "subagents" / "python_sec" / "AGENT.md"
|
|
138
|
+
|
|
121
139
|
def test_run_headless(self, tmp_path):
|
|
122
140
|
agent = CodexAgent()
|
|
123
141
|
mock_result = MagicMock()
|
|
@@ -173,6 +191,24 @@ class TestClaudeAgent:
|
|
|
173
191
|
agent = ClaudeAgent()
|
|
174
192
|
assert agent.output_file == "CLAUDE.md"
|
|
175
193
|
|
|
194
|
+
def test_supports_skills(self):
|
|
195
|
+
agent = ClaudeAgent()
|
|
196
|
+
assert agent.supports_skills() is True
|
|
197
|
+
|
|
198
|
+
def test_skill_path(self, tmp_path):
|
|
199
|
+
agent = ClaudeAgent()
|
|
200
|
+
path = agent.skill_path(tmp_path, "review")
|
|
201
|
+
assert path == tmp_path / ".claude" / "skills" / "review" / "SKILL.md"
|
|
202
|
+
|
|
203
|
+
def test_supports_subagents(self):
|
|
204
|
+
agent = ClaudeAgent()
|
|
205
|
+
assert agent.supports_subagents() is True
|
|
206
|
+
|
|
207
|
+
def test_subagent_path(self, tmp_path):
|
|
208
|
+
agent = ClaudeAgent()
|
|
209
|
+
path = agent.subagent_path(tmp_path, "python_sec")
|
|
210
|
+
assert path == tmp_path / ".claude" / "subagents" / "python_sec" / "AGENT.md"
|
|
211
|
+
|
|
176
212
|
def test_run_headless(self, tmp_path):
|
|
177
213
|
agent = ClaudeAgent()
|
|
178
214
|
|
|
@@ -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
|
|
|
@@ -132,6 +146,8 @@ class TestCmdConjure:
|
|
|
132
146
|
agent="claude",
|
|
133
147
|
conjurings=["python"],
|
|
134
148
|
into=str(tmp_path),
|
|
149
|
+
save_subagent=False,
|
|
150
|
+
subagent_name=None,
|
|
135
151
|
)
|
|
136
152
|
result = cmd_conjure(args)
|
|
137
153
|
assert result == 0
|
|
@@ -144,10 +160,55 @@ class TestCmdConjure:
|
|
|
144
160
|
agent="claude",
|
|
145
161
|
conjurings=["nonexistent"],
|
|
146
162
|
into=str(tmp_path),
|
|
163
|
+
save_subagent=False,
|
|
164
|
+
subagent_name=None,
|
|
147
165
|
)
|
|
148
166
|
with pytest.raises(CliError, match="unknown conjuring"):
|
|
149
167
|
cmd_conjure(args)
|
|
150
168
|
|
|
169
|
+
def test_writes_subagent_for_claude(self, tmp_path):
|
|
170
|
+
args = argparse.Namespace(
|
|
171
|
+
agent="claude",
|
|
172
|
+
conjurings=["python", "sec"],
|
|
173
|
+
into=str(tmp_path),
|
|
174
|
+
save_subagent=True,
|
|
175
|
+
subagent_name=None,
|
|
176
|
+
)
|
|
177
|
+
result = cmd_conjure(args)
|
|
178
|
+
assert result == 0
|
|
179
|
+
subagent = tmp_path / ".claude" / "subagents" / "python_sec" / "AGENT.md"
|
|
180
|
+
assert subagent.exists()
|
|
181
|
+
content = subagent.read_text()
|
|
182
|
+
assert "conjurings: python, sec" in content
|
|
183
|
+
assert "agent: claude" in content
|
|
184
|
+
|
|
185
|
+
def test_writes_subagent_for_codex_with_custom_name(self, tmp_path):
|
|
186
|
+
args = argparse.Namespace(
|
|
187
|
+
agent="codex",
|
|
188
|
+
conjurings=["rust", "infra"],
|
|
189
|
+
into=str(tmp_path),
|
|
190
|
+
save_subagent=True,
|
|
191
|
+
subagent_name="ship_ops",
|
|
192
|
+
)
|
|
193
|
+
result = cmd_conjure(args)
|
|
194
|
+
assert result == 0
|
|
195
|
+
subagent = tmp_path / ".codex" / "subagents" / "ship_ops" / "AGENT.md"
|
|
196
|
+
assert subagent.exists()
|
|
197
|
+
content = subagent.read_text()
|
|
198
|
+
assert "conjurings: rust, infra" in content
|
|
199
|
+
assert "agent: codex" in content
|
|
200
|
+
|
|
201
|
+
def test_invalid_subagent_name_raises(self, tmp_path):
|
|
202
|
+
args = argparse.Namespace(
|
|
203
|
+
agent="claude",
|
|
204
|
+
conjurings=["python"],
|
|
205
|
+
into=str(tmp_path),
|
|
206
|
+
save_subagent=True,
|
|
207
|
+
subagent_name="Not Valid",
|
|
208
|
+
)
|
|
209
|
+
with pytest.raises(CliError, match="invalid subagent name"):
|
|
210
|
+
cmd_conjure(args)
|
|
211
|
+
|
|
151
212
|
|
|
152
213
|
class TestCmdInvoke:
|
|
153
214
|
"""tests for invoke command."""
|
|
@@ -164,6 +225,8 @@ class TestCmdInvoke:
|
|
|
164
225
|
kv=None,
|
|
165
226
|
inv_args=["some code"],
|
|
166
227
|
worktree=False,
|
|
228
|
+
save_skill=False,
|
|
229
|
+
skill_name=None,
|
|
167
230
|
)
|
|
168
231
|
result = cmd_invoke(args)
|
|
169
232
|
assert result == 0
|
|
@@ -183,6 +246,8 @@ class TestCmdInvoke:
|
|
|
183
246
|
kv=None,
|
|
184
247
|
inv_args=[],
|
|
185
248
|
worktree=False,
|
|
249
|
+
save_skill=False,
|
|
250
|
+
skill_name=None,
|
|
186
251
|
)
|
|
187
252
|
with pytest.raises(CliError, match="unknown invocation"):
|
|
188
253
|
cmd_invoke(args)
|
|
@@ -204,6 +269,8 @@ class TestCmdInvoke:
|
|
|
204
269
|
kv=["mykey=myvalue"],
|
|
205
270
|
inv_args=[],
|
|
206
271
|
worktree=False,
|
|
272
|
+
save_skill=False,
|
|
273
|
+
skill_name=None,
|
|
207
274
|
)
|
|
208
275
|
cmd_invoke(args)
|
|
209
276
|
prompt = mock_run.call_args[0][2]
|
|
@@ -220,6 +287,8 @@ class TestCmdInvoke:
|
|
|
220
287
|
kv=None,
|
|
221
288
|
inv_args=["some code"],
|
|
222
289
|
worktree=False,
|
|
290
|
+
save_skill=False,
|
|
291
|
+
skill_name=None,
|
|
223
292
|
)
|
|
224
293
|
result = cmd_invoke(args)
|
|
225
294
|
assert result == 0
|
|
@@ -238,6 +307,8 @@ class TestCmdInvoke:
|
|
|
238
307
|
kv=None,
|
|
239
308
|
inv_args=[],
|
|
240
309
|
worktree=False,
|
|
310
|
+
save_skill=False,
|
|
311
|
+
skill_name=None,
|
|
241
312
|
)
|
|
242
313
|
cmd_invoke(args)
|
|
243
314
|
mock_run.assert_not_called()
|
|
@@ -261,6 +332,8 @@ class TestCmdInvoke:
|
|
|
261
332
|
kv=None,
|
|
262
333
|
inv_args=[],
|
|
263
334
|
worktree=True,
|
|
335
|
+
save_skill=False,
|
|
336
|
+
skill_name=None,
|
|
264
337
|
)
|
|
265
338
|
result = cmd_invoke(args)
|
|
266
339
|
assert result == 0
|
|
@@ -270,6 +343,92 @@ class TestCmdInvoke:
|
|
|
270
343
|
# Check that instruction file was copied
|
|
271
344
|
mock_copy.assert_called_once()
|
|
272
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
|
+
|
|
367
|
+
def test_invoke_save_skill_claude(self, tmp_path):
|
|
368
|
+
with patch("familiar.cli.run_agent") as mock_run:
|
|
369
|
+
args = argparse.Namespace(
|
|
370
|
+
agent="claude",
|
|
371
|
+
invocation="explain",
|
|
372
|
+
into=str(tmp_path),
|
|
373
|
+
headless=True,
|
|
374
|
+
auto=False,
|
|
375
|
+
dry_run=False,
|
|
376
|
+
kv=None,
|
|
377
|
+
inv_args=["some code"],
|
|
378
|
+
worktree=False,
|
|
379
|
+
save_skill=True,
|
|
380
|
+
skill_name=None,
|
|
381
|
+
)
|
|
382
|
+
result = cmd_invoke(args)
|
|
383
|
+
assert result == 0
|
|
384
|
+
skill = tmp_path / ".claude" / "skills" / "explain" / "SKILL.md"
|
|
385
|
+
assert skill.exists()
|
|
386
|
+
content = skill.read_text()
|
|
387
|
+
assert "invocation: explain" in content
|
|
388
|
+
assert "agent: claude" in content
|
|
389
|
+
mock_run.assert_not_called()
|
|
390
|
+
|
|
391
|
+
def test_invoke_save_skill_codex_custom_name(self, tmp_path):
|
|
392
|
+
with patch("familiar.cli.run_agent") as mock_run:
|
|
393
|
+
args = argparse.Namespace(
|
|
394
|
+
agent="codex",
|
|
395
|
+
invocation="refactor",
|
|
396
|
+
into=str(tmp_path),
|
|
397
|
+
headless=True,
|
|
398
|
+
auto=False,
|
|
399
|
+
dry_run=False,
|
|
400
|
+
kv=None,
|
|
401
|
+
inv_args=["src/foo.py"],
|
|
402
|
+
worktree=False,
|
|
403
|
+
save_skill=True,
|
|
404
|
+
skill_name="cleanup_refactor",
|
|
405
|
+
)
|
|
406
|
+
result = cmd_invoke(args)
|
|
407
|
+
assert result == 0
|
|
408
|
+
skill = tmp_path / ".codex" / "skills" / "cleanup_refactor" / "SKILL.md"
|
|
409
|
+
assert skill.exists()
|
|
410
|
+
content = skill.read_text()
|
|
411
|
+
assert "invocation: refactor" in content
|
|
412
|
+
assert "agent: codex" in content
|
|
413
|
+
mock_run.assert_not_called()
|
|
414
|
+
|
|
415
|
+
def test_invoke_save_skill_invalid_name(self, tmp_path):
|
|
416
|
+
args = argparse.Namespace(
|
|
417
|
+
agent="claude",
|
|
418
|
+
invocation="explain",
|
|
419
|
+
into=str(tmp_path),
|
|
420
|
+
headless=True,
|
|
421
|
+
auto=False,
|
|
422
|
+
dry_run=False,
|
|
423
|
+
kv=None,
|
|
424
|
+
inv_args=["some code"],
|
|
425
|
+
worktree=False,
|
|
426
|
+
save_skill=True,
|
|
427
|
+
skill_name="Not Valid",
|
|
428
|
+
)
|
|
429
|
+
with pytest.raises(CliError, match="invalid skill name"):
|
|
430
|
+
cmd_invoke(args)
|
|
431
|
+
|
|
273
432
|
|
|
274
433
|
class TestCmdList:
|
|
275
434
|
"""tests for list command."""
|
|
@@ -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
|
{familiar_cli-0.3.1 → 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
|