familiar-cli 0.5.2__tar.gz → 0.6.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.5.2 → familiar_cli-0.6.0}/PKG-INFO +14 -5
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/pyproject.toml +2 -2
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/readme.md +12 -3
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/__init__.py +1 -1
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/_plugins.py +4 -2
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/agents.py +3 -2
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/cli.py +7 -5
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/lint.py +147 -53
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/render.py +37 -20
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/PKG-INFO +14 -5
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/requires.txt +1 -1
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_agents.py +5 -4
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_cli.py +108 -89
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_integration.py +20 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_lint.py +284 -2
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/tests/test_render.py +61 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/LICENSE +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/setup.cfg +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/core.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/data.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/docs.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/frontend.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/infra.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/python.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/rust.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/conjurings/sec.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/__noop__.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-ci.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/add-tests.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/audit.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-python.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/bootstrap-rust.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/code-review.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/explain.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/implement-feature.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/infra-change.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/performance.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/refactor.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/release.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/invocations/security-review.md +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/github-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/node/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/github-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/python/pyproject.toml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/Cargo.toml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/github-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/data/snippets/rust/gitlab-ci.yml +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar/py.typed +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/SOURCES.txt +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/dependency_links.txt +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/entry_points.txt +0 -0
- {familiar_cli-0.5.2 → familiar_cli-0.6.0}/src/familiar_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff==0.16.1; extra == "dev"
|
|
24
24
|
Requires-Dist: mypy; extra == "dev"
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -46,18 +46,21 @@ pip install familiar-cli
|
|
|
46
46
|
## usage
|
|
47
47
|
|
|
48
48
|
```
|
|
49
|
-
usage: familiar [-h] {conjure,invoke,list} ...
|
|
49
|
+
usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
|
|
50
50
|
|
|
51
51
|
conjure and invoke familiars
|
|
52
52
|
|
|
53
53
|
positional arguments:
|
|
54
|
-
{conjure,invoke,list}
|
|
54
|
+
{conjure,invoke,list,lint}
|
|
55
55
|
conjure compose system instructions for an agent
|
|
56
56
|
invoke render an invocation and run the agent
|
|
57
|
-
list list available conjurings
|
|
57
|
+
list list available conjurings and invocations
|
|
58
|
+
lint lint conjurings and invocations
|
|
58
59
|
|
|
59
60
|
options:
|
|
60
61
|
-h, --help show this help message and exit
|
|
62
|
+
--version show program's version number and exit
|
|
63
|
+
--debug show full traceback on error
|
|
61
64
|
```
|
|
62
65
|
|
|
63
66
|
conjure conjurings to create system instructions for an agent:
|
|
@@ -98,6 +101,12 @@ list available conjurings and invocations:
|
|
|
98
101
|
familiar list
|
|
99
102
|
```
|
|
100
103
|
|
|
104
|
+
lint conjurings and invocations, including local `.familiar/` overrides:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
familiar lint
|
|
108
|
+
```
|
|
109
|
+
|
|
101
110
|
## customization
|
|
102
111
|
|
|
103
112
|
add your own conjurings and invocations by creating files in `.familiar/` in your repo:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "familiar-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.0"
|
|
4
4
|
description = "compose and invoke ai agent prompts from reusable templates"
|
|
5
5
|
readme = "readme.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -36,7 +36,7 @@ claude = "familiar.agents:ClaudeAgent"
|
|
|
36
36
|
package-dir = {"" = "src"}
|
|
37
37
|
|
|
38
38
|
[project.optional-dependencies]
|
|
39
|
-
dev = ["ruff", "mypy", "pytest", "pytest-cov", "bandit"]
|
|
39
|
+
dev = ["ruff==0.16.1", "mypy", "pytest", "pytest-cov", "bandit"]
|
|
40
40
|
|
|
41
41
|
[tool.setuptools.package-data]
|
|
42
42
|
"familiar.data.conjurings" = ["*.md"]
|
|
@@ -17,18 +17,21 @@ pip install familiar-cli
|
|
|
17
17
|
## usage
|
|
18
18
|
|
|
19
19
|
```
|
|
20
|
-
usage: familiar [-h] {conjure,invoke,list} ...
|
|
20
|
+
usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
|
|
21
21
|
|
|
22
22
|
conjure and invoke familiars
|
|
23
23
|
|
|
24
24
|
positional arguments:
|
|
25
|
-
{conjure,invoke,list}
|
|
25
|
+
{conjure,invoke,list,lint}
|
|
26
26
|
conjure compose system instructions for an agent
|
|
27
27
|
invoke render an invocation and run the agent
|
|
28
|
-
list list available conjurings
|
|
28
|
+
list list available conjurings and invocations
|
|
29
|
+
lint lint conjurings and invocations
|
|
29
30
|
|
|
30
31
|
options:
|
|
31
32
|
-h, --help show this help message and exit
|
|
33
|
+
--version show program's version number and exit
|
|
34
|
+
--debug show full traceback on error
|
|
32
35
|
```
|
|
33
36
|
|
|
34
37
|
conjure conjurings to create system instructions for an agent:
|
|
@@ -69,6 +72,12 @@ list available conjurings and invocations:
|
|
|
69
72
|
familiar list
|
|
70
73
|
```
|
|
71
74
|
|
|
75
|
+
lint conjurings and invocations, including local `.familiar/` overrides:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
familiar lint
|
|
79
|
+
```
|
|
80
|
+
|
|
72
81
|
## customization
|
|
73
82
|
|
|
74
83
|
add your own conjurings and invocations by creating files in `.familiar/` in your repo:
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import warnings
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from importlib.metadata import entry_points
|
|
7
|
-
from typing import Any
|
|
8
|
+
from typing import Any
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def load_plugins(
|
|
@@ -36,7 +37,8 @@ def load_plugins(
|
|
|
36
37
|
)
|
|
37
38
|
continue
|
|
38
39
|
results.append(obj)
|
|
39
|
-
|
|
40
|
+
# a broken third-party entry point must not take down the cli
|
|
41
|
+
except Exception as e: # noqa: BLE001
|
|
40
42
|
warnings.warn(
|
|
41
43
|
f"failed to load {label} '{ep.name}': {e}",
|
|
42
44
|
stacklevel=2,
|
|
@@ -61,7 +61,7 @@ class CodexAgent(Agent):
|
|
|
61
61
|
if auto:
|
|
62
62
|
cmd.append("--full-auto")
|
|
63
63
|
cmd.extend(["exec", "--skip-git-repo-check", "-C", str(repo_root), "-"])
|
|
64
|
-
proc = subprocess.run(cmd, input=prompt, text=True)
|
|
64
|
+
proc = subprocess.run(cmd, input=prompt, text=True, check=False)
|
|
65
65
|
return proc.returncode
|
|
66
66
|
else:
|
|
67
67
|
cmd = ["codex"]
|
|
@@ -108,7 +108,8 @@ def load_agents() -> dict[str, Agent]:
|
|
|
108
108
|
try:
|
|
109
109
|
instance = cls()
|
|
110
110
|
agents[instance.name] = instance
|
|
111
|
-
|
|
111
|
+
# a broken agent plugin must not take down the cli
|
|
112
|
+
except Exception as e: # noqa: BLE001
|
|
112
113
|
warnings.warn(
|
|
113
114
|
f"failed to load agent plugin '{cls.__name__}': {e}",
|
|
114
115
|
stacklevel=2,
|
|
@@ -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 Agent,
|
|
17
|
+
from .agents import Agent, get_agent, get_agents
|
|
18
18
|
from .lint import lint_all
|
|
19
19
|
from .render import (
|
|
20
20
|
NotFoundError,
|
|
@@ -46,7 +46,7 @@ def _agent_hint() -> str:
|
|
|
46
46
|
return f"valid agents: {', '.join(get_agents().keys())}"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
-
def _resolve_agent(name: str) ->
|
|
49
|
+
def _resolve_agent(name: str) -> Agent:
|
|
50
50
|
"""look up an agent by name, raising CliError on unknown names."""
|
|
51
51
|
try:
|
|
52
52
|
return get_agent(name)
|
|
@@ -94,6 +94,7 @@ def _remove_worktree(repo_root: Path, worktree_path: Path) -> None:
|
|
|
94
94
|
str(worktree_path),
|
|
95
95
|
],
|
|
96
96
|
capture_output=True,
|
|
97
|
+
check=False,
|
|
97
98
|
)
|
|
98
99
|
if result.returncode != 0:
|
|
99
100
|
stderr = result.stderr.decode(errors="replace").strip()
|
|
@@ -119,8 +120,8 @@ def create_worktree(repo_root: Path) -> Path:
|
|
|
119
120
|
)
|
|
120
121
|
|
|
121
122
|
tmpdir = tempfile.mkdtemp(prefix="familiar-")
|
|
122
|
-
# git worktree add requires the
|
|
123
|
-
# mkdtemp
|
|
123
|
+
# git worktree add requires the target to not exist, so remove the
|
|
124
|
+
# mkdtemp-created directory and let git create it
|
|
124
125
|
os.rmdir(tmpdir)
|
|
125
126
|
|
|
126
127
|
try:
|
|
@@ -509,7 +510,8 @@ def main() -> None:
|
|
|
509
510
|
if e.hint:
|
|
510
511
|
print(f"hint: {e.hint}", file=sys.stderr)
|
|
511
512
|
raise SystemExit(e.exit_code)
|
|
512
|
-
|
|
513
|
+
# top-level handler: any unexpected error becomes an exit code, not a traceback
|
|
514
|
+
except Exception as e: # noqa: BLE001
|
|
513
515
|
if args.debug:
|
|
514
516
|
traceback.print_exc()
|
|
515
517
|
print(f"error: {e}", file=sys.stderr)
|
|
@@ -3,13 +3,22 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
|
+
from collections.abc import Callable
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from pathlib import Path
|
|
8
|
-
from typing import
|
|
9
|
+
from typing import Literal
|
|
9
10
|
|
|
10
11
|
from ._plugins import load_plugins
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
from .render import (
|
|
13
|
+
_SNIPPET_INCLUDE,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
_expand_includes,
|
|
16
|
+
list_items,
|
|
17
|
+
list_snippets,
|
|
18
|
+
load_snippet,
|
|
19
|
+
load_text,
|
|
20
|
+
resolve_includes,
|
|
21
|
+
)
|
|
13
22
|
|
|
14
23
|
|
|
15
24
|
@dataclass
|
|
@@ -41,7 +50,7 @@ _INPUTS_SECTION = re.compile(
|
|
|
41
50
|
r"^(##\s+)?(inputs?|arguments?)(\s*\([^)]+\))?:?\s*$", re.IGNORECASE | re.MULTILINE
|
|
42
51
|
)
|
|
43
52
|
_OUTPUT_SECTION = re.compile(
|
|
44
|
-
r"^(##\s+)?(
|
|
53
|
+
r"^(##\s+)?(outputs?|deliverables?):?\s*$", re.IGNORECASE | re.MULTILINE
|
|
45
54
|
)
|
|
46
55
|
|
|
47
56
|
|
|
@@ -79,6 +88,61 @@ def lint_template(content: str, name: str) -> list[LintMessage]:
|
|
|
79
88
|
return messages
|
|
80
89
|
|
|
81
90
|
|
|
91
|
+
def _check_placeholder_docs(
|
|
92
|
+
positional: set[str], named: set[str], doc_content: str, name: str
|
|
93
|
+
) -> list[LintMessage]:
|
|
94
|
+
"""warn about placeholders not documented in ``doc_content``'s inputs section."""
|
|
95
|
+
messages: list[LintMessage] = []
|
|
96
|
+
|
|
97
|
+
# loose check: prefer the inputs section if it exists, else the whole file
|
|
98
|
+
inputs_match = _INPUTS_SECTION.search(doc_content)
|
|
99
|
+
if inputs_match:
|
|
100
|
+
start = inputs_match.end()
|
|
101
|
+
# look ahead for the next markdown heading or end of file
|
|
102
|
+
next_heading = re.search(r"^#", doc_content[start:], re.MULTILINE)
|
|
103
|
+
search_area = (
|
|
104
|
+
doc_content[start : start + next_heading.start()]
|
|
105
|
+
if next_heading
|
|
106
|
+
else doc_content[start:]
|
|
107
|
+
).lower()
|
|
108
|
+
else:
|
|
109
|
+
search_area = doc_content.lower()
|
|
110
|
+
|
|
111
|
+
for placeholder in named:
|
|
112
|
+
tag = f"{{{{{placeholder.lower()}}}}}"
|
|
113
|
+
stripped = search_area.replace(tag, "")
|
|
114
|
+
# accept the placeholder as documented if its bare name appears as a
|
|
115
|
+
# standalone word (after stripping {{…}} syntax) OR if the {{…}} tag
|
|
116
|
+
# itself appears in the inputs section (the common "- {{name}}: …"
|
|
117
|
+
# documentation pattern).
|
|
118
|
+
bare_match = re.search(r"\b" + re.escape(placeholder.lower()) + r"\b", stripped)
|
|
119
|
+
if not bare_match and tag not in search_area:
|
|
120
|
+
messages.append(
|
|
121
|
+
LintMessage(
|
|
122
|
+
level="warning",
|
|
123
|
+
file=name,
|
|
124
|
+
line=None,
|
|
125
|
+
message=f"placeholder '{{{{{placeholder}}}}}' may not be documented in inputs",
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
for p in positional:
|
|
130
|
+
if p == "ARGUMENTS":
|
|
131
|
+
continue
|
|
132
|
+
pattern = rf"(\w+[:\s]+\${p}|\${p}\s+[`\w]|\${p}\s*\()"
|
|
133
|
+
if not re.search(pattern, search_area):
|
|
134
|
+
messages.append(
|
|
135
|
+
LintMessage(
|
|
136
|
+
level="warning",
|
|
137
|
+
file=name,
|
|
138
|
+
line=None,
|
|
139
|
+
message=f"placeholder '${p}' may not be documented in inputs",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return messages
|
|
144
|
+
|
|
145
|
+
|
|
82
146
|
def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
83
147
|
"""lint an invocation file.
|
|
84
148
|
|
|
@@ -135,52 +199,7 @@ def lint_invocation(content: str, name: str) -> list[LintMessage]:
|
|
|
135
199
|
|
|
136
200
|
positional = set(_POSITIONAL_PLACEHOLDER.findall(content))
|
|
137
201
|
named = set(_NAMED_PLACEHOLDER.findall(content))
|
|
138
|
-
|
|
139
|
-
# loose check: prefer the inputs section if it exists, else the whole file
|
|
140
|
-
inputs_match = _INPUTS_SECTION.search(content)
|
|
141
|
-
if inputs_match:
|
|
142
|
-
start = inputs_match.end()
|
|
143
|
-
# look ahead for the next markdown heading or end of file
|
|
144
|
-
next_heading = re.search(r"^#", content[start:], re.MULTILINE)
|
|
145
|
-
search_area = (
|
|
146
|
-
content[start : start + next_heading.start()]
|
|
147
|
-
if next_heading
|
|
148
|
-
else content[start:]
|
|
149
|
-
).lower()
|
|
150
|
-
else:
|
|
151
|
-
search_area = content.lower()
|
|
152
|
-
|
|
153
|
-
for placeholder in named:
|
|
154
|
-
tag = f"{{{{{placeholder.lower()}}}}}"
|
|
155
|
-
stripped = search_area.replace(tag, "")
|
|
156
|
-
# accept the placeholder as documented if its bare name appears as a
|
|
157
|
-
# standalone word (after stripping {{…}} syntax) OR if the {{…}} tag
|
|
158
|
-
# itself appears in the inputs section (the common "- {{name}}: …"
|
|
159
|
-
# documentation pattern).
|
|
160
|
-
bare_match = re.search(r"\b" + re.escape(placeholder.lower()) + r"\b", stripped)
|
|
161
|
-
if not bare_match and tag not in search_area:
|
|
162
|
-
messages.append(
|
|
163
|
-
LintMessage(
|
|
164
|
-
level="warning",
|
|
165
|
-
file=name,
|
|
166
|
-
line=None,
|
|
167
|
-
message=f"placeholder '{{{{{placeholder}}}}}' may not be documented in inputs",
|
|
168
|
-
)
|
|
169
|
-
)
|
|
170
|
-
|
|
171
|
-
for p in positional:
|
|
172
|
-
if p == "ARGUMENTS":
|
|
173
|
-
continue
|
|
174
|
-
pattern = rf"(\w+[:\s]+\${p}|\${p}\s+[`\w]|\${p}\s*\()"
|
|
175
|
-
if not re.search(pattern, search_area):
|
|
176
|
-
messages.append(
|
|
177
|
-
LintMessage(
|
|
178
|
-
level="warning",
|
|
179
|
-
file=name,
|
|
180
|
-
line=None,
|
|
181
|
-
message=f"placeholder '${p}' may not be documented in inputs",
|
|
182
|
-
)
|
|
183
|
-
)
|
|
202
|
+
messages.extend(_check_placeholder_docs(positional, named, content, name))
|
|
184
203
|
|
|
185
204
|
return messages
|
|
186
205
|
|
|
@@ -208,13 +227,19 @@ def load_linters(kind: Literal["conjurings", "invocations"]) -> list[LinterFunc]
|
|
|
208
227
|
def lint_snippet_references(
|
|
209
228
|
repo_root: Path, content: str, name: str
|
|
210
229
|
) -> list[LintMessage]:
|
|
211
|
-
"""check that
|
|
230
|
+
"""check that snippet includes resolve, following them transitively.
|
|
231
|
+
|
|
232
|
+
each top-level ``{{> snippet:path}}`` directive is validated along with
|
|
233
|
+
every snippet it pulls in, mirroring how the renderer expands includes at
|
|
234
|
+
conjure/invoke time. a broken reference, an include cycle, or an over-deep
|
|
235
|
+
chain is reported against the line of the top-level directive.
|
|
236
|
+
"""
|
|
212
237
|
messages: list[LintMessage] = []
|
|
213
238
|
for i, line in enumerate(content.split("\n"), 1):
|
|
214
239
|
for m in _SNIPPET_INCLUDE.finditer(line):
|
|
215
240
|
snippet_path = m.group(1).strip()
|
|
216
241
|
try:
|
|
217
|
-
load_snippet(repo_root, snippet_path)
|
|
242
|
+
body = load_snippet(repo_root, snippet_path)
|
|
218
243
|
except NotFoundError:
|
|
219
244
|
messages.append(
|
|
220
245
|
LintMessage(
|
|
@@ -224,9 +249,74 @@ def lint_snippet_references(
|
|
|
224
249
|
message=f"snippet not found: {snippet_path}",
|
|
225
250
|
)
|
|
226
251
|
)
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
_expand_includes(repo_root, body, [snippet_path])
|
|
255
|
+
except NotFoundError as e:
|
|
256
|
+
messages.append(
|
|
257
|
+
LintMessage(
|
|
258
|
+
level="error",
|
|
259
|
+
file=name,
|
|
260
|
+
line=i,
|
|
261
|
+
message=str(e),
|
|
262
|
+
)
|
|
263
|
+
)
|
|
227
264
|
return messages
|
|
228
265
|
|
|
229
266
|
|
|
267
|
+
def lint_snippet_collection(repo_root: Path) -> list[LintMessage]:
|
|
268
|
+
"""lint every snippet body for broken, cyclic, or over-deep includes.
|
|
269
|
+
|
|
270
|
+
a snippet may include other snippets; a bad snippet->snippet include stays
|
|
271
|
+
invisible until something transitively pulls it in. each snippet is checked
|
|
272
|
+
against its own path.
|
|
273
|
+
"""
|
|
274
|
+
messages: list[LintMessage] = []
|
|
275
|
+
for path, _, is_local in list_snippets(repo_root):
|
|
276
|
+
prefix = (
|
|
277
|
+
f".familiar/snippets/{path}" if is_local else f"(builtin) snippets/{path}"
|
|
278
|
+
)
|
|
279
|
+
try:
|
|
280
|
+
content = load_snippet(repo_root, path)
|
|
281
|
+
except NotFoundError as e:
|
|
282
|
+
messages.append(
|
|
283
|
+
LintMessage(
|
|
284
|
+
level="error",
|
|
285
|
+
file=prefix,
|
|
286
|
+
line=None,
|
|
287
|
+
message=f"failed to load: {e}",
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
continue
|
|
291
|
+
messages.extend(lint_snippet_references(repo_root, content, prefix))
|
|
292
|
+
return messages
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def lint_snippet_placeholders(
|
|
296
|
+
repo_root: Path, content: str, name: str
|
|
297
|
+
) -> list[LintMessage]:
|
|
298
|
+
"""check placeholders that included snippets contribute to an invocation.
|
|
299
|
+
|
|
300
|
+
the renderer expands includes before substituting, so a ``$1``/``{{key}}``
|
|
301
|
+
inside an included snippet is live at invoke time but invisible to the
|
|
302
|
+
raw-text check in :func:`lint_invocation`. only the snippet-contributed
|
|
303
|
+
delta is checked here; broken or cyclic includes are left to
|
|
304
|
+
:func:`lint_snippet_references`.
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
expanded = resolve_includes(repo_root, content)
|
|
308
|
+
except NotFoundError:
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
positional = set(_POSITIONAL_PLACEHOLDER.findall(expanded)) - set(
|
|
312
|
+
_POSITIONAL_PLACEHOLDER.findall(content)
|
|
313
|
+
)
|
|
314
|
+
named = set(_NAMED_PLACEHOLDER.findall(expanded)) - set(
|
|
315
|
+
_NAMED_PLACEHOLDER.findall(content)
|
|
316
|
+
)
|
|
317
|
+
return _check_placeholder_docs(positional, named, content, name)
|
|
318
|
+
|
|
319
|
+
|
|
230
320
|
def lint_collection(
|
|
231
321
|
repo_root: Path,
|
|
232
322
|
kind: Literal["conjurings", "invocations"],
|
|
@@ -245,10 +335,13 @@ def lint_collection(
|
|
|
245
335
|
)
|
|
246
336
|
messages.extend(builtin_linter(content, prefix))
|
|
247
337
|
messages.extend(lint_snippet_references(repo_root, content, prefix))
|
|
338
|
+
if kind == "invocations":
|
|
339
|
+
messages.extend(lint_snippet_placeholders(repo_root, content, prefix))
|
|
248
340
|
for linter in plugin_linters:
|
|
249
341
|
try:
|
|
250
342
|
messages.extend(linter(content, prefix))
|
|
251
|
-
|
|
343
|
+
# a broken plugin linter is reported, not fatal to the run
|
|
344
|
+
except Exception as e: # noqa: BLE001
|
|
252
345
|
messages.append(
|
|
253
346
|
LintMessage(
|
|
254
347
|
level="error",
|
|
@@ -287,5 +380,6 @@ def lint_all(repo_root: Path) -> list[LintMessage]:
|
|
|
287
380
|
messages.extend(
|
|
288
381
|
lint_collection(repo_root, "invocations", lint_invocation, invocation_linters)
|
|
289
382
|
)
|
|
383
|
+
messages.extend(lint_snippet_collection(repo_root))
|
|
290
384
|
|
|
291
385
|
return messages
|
|
@@ -70,6 +70,32 @@ def load_snippet(repo_root: Path, path: str) -> str:
|
|
|
70
70
|
raise NotFoundError(f"unknown snippet: {path}")
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def _expand_includes(repo_root: Path, text: str, chain: list[str]) -> str:
|
|
74
|
+
"""recursively expand {{> snippet:path}} includes in ``text``.
|
|
75
|
+
|
|
76
|
+
``chain`` is the stack of ancestor snippet paths currently being expanded,
|
|
77
|
+
used to detect include cycles and cap nesting depth. shared by
|
|
78
|
+
:func:`resolve_includes` and the linter so both agree on exactly what
|
|
79
|
+
resolves, cycles, or exceeds depth.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def repl(m: re.Match[str]) -> str:
|
|
83
|
+
path = m.group(1).strip()
|
|
84
|
+
if path in chain:
|
|
85
|
+
trail = " -> ".join([*chain, path])
|
|
86
|
+
raise NotFoundError(f"snippet include cycle: {trail}")
|
|
87
|
+
if len(chain) >= _MAX_INCLUDE_DEPTH:
|
|
88
|
+
trail = " -> ".join([*chain, path])
|
|
89
|
+
raise NotFoundError(
|
|
90
|
+
f"snippet include depth exceeded ({_MAX_INCLUDE_DEPTH}): {trail}"
|
|
91
|
+
)
|
|
92
|
+
return _expand_includes(
|
|
93
|
+
repo_root, load_snippet(repo_root, path), [*chain, path]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return _SNIPPET_INCLUDE.sub(repl, text)
|
|
97
|
+
|
|
98
|
+
|
|
73
99
|
def resolve_includes(repo_root: Path, text: str) -> str:
|
|
74
100
|
"""resolve {{> snippet:path}} includes, expanding nested includes recursively.
|
|
75
101
|
|
|
@@ -77,23 +103,7 @@ def resolve_includes(repo_root: Path, text: str) -> str:
|
|
|
77
103
|
a -> b -> a) raise :class:`NotFoundError` naming the chain; a max-depth
|
|
78
104
|
backstop guards against pathologically deep nesting.
|
|
79
105
|
"""
|
|
80
|
-
|
|
81
|
-
def expand(text: str, chain: list[str]) -> str:
|
|
82
|
-
def repl(m: re.Match[str]) -> str:
|
|
83
|
-
path = m.group(1).strip()
|
|
84
|
-
if path in chain:
|
|
85
|
-
trail = " -> ".join([*chain, path])
|
|
86
|
-
raise NotFoundError(f"snippet include cycle: {trail}")
|
|
87
|
-
if len(chain) >= _MAX_INCLUDE_DEPTH:
|
|
88
|
-
trail = " -> ".join([*chain, path])
|
|
89
|
-
raise NotFoundError(
|
|
90
|
-
f"snippet include depth exceeded ({_MAX_INCLUDE_DEPTH}): {trail}"
|
|
91
|
-
)
|
|
92
|
-
return expand(load_snippet(repo_root, path), [*chain, path])
|
|
93
|
-
|
|
94
|
-
return _SNIPPET_INCLUDE.sub(repl, text)
|
|
95
|
-
|
|
96
|
-
return expand(text, [])
|
|
106
|
+
return _expand_includes(repo_root, text, [])
|
|
97
107
|
|
|
98
108
|
|
|
99
109
|
def substitute(text: str, args: list[str], kv: dict[str, str]) -> str:
|
|
@@ -238,11 +248,18 @@ def list_items(repo_root: Path, kind: str) -> list[tuple[str, str, bool]]:
|
|
|
238
248
|
|
|
239
249
|
|
|
240
250
|
def compose_system(repo_root: Path, conjurings: list[str]) -> str:
|
|
241
|
-
"""compose system instructions from core + selected conjurings.
|
|
251
|
+
"""compose system instructions from core + selected conjurings.
|
|
252
|
+
|
|
253
|
+
snippet includes ({{> snippet:path}}) in the core text and in each
|
|
254
|
+
conjuring are expanded, mirroring :func:`render_invocation`. conjurings
|
|
255
|
+
receive no placeholder substitution, so any $N/{{key}} in an included
|
|
256
|
+
snippet is left verbatim.
|
|
257
|
+
"""
|
|
242
258
|
core = load_text(repo_root, "conjurings", "core").strip()
|
|
243
|
-
parts: list[str] = [core]
|
|
259
|
+
parts: list[str] = [resolve_includes(repo_root, core)]
|
|
244
260
|
for name in conjurings:
|
|
245
|
-
|
|
261
|
+
text = load_text(repo_root, "conjurings", name).strip()
|
|
262
|
+
parts.append(resolve_includes(repo_root, text))
|
|
246
263
|
return "\n\n".join(parts)
|
|
247
264
|
|
|
248
265
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: familiar-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -20,7 +20,7 @@ Requires-Python: >=3.10
|
|
|
20
20
|
Description-Content-Type: text/markdown
|
|
21
21
|
License-File: LICENSE
|
|
22
22
|
Provides-Extra: dev
|
|
23
|
-
Requires-Dist: ruff; extra == "dev"
|
|
23
|
+
Requires-Dist: ruff==0.16.1; extra == "dev"
|
|
24
24
|
Requires-Dist: mypy; extra == "dev"
|
|
25
25
|
Requires-Dist: pytest; extra == "dev"
|
|
26
26
|
Requires-Dist: pytest-cov; extra == "dev"
|
|
@@ -46,18 +46,21 @@ pip install familiar-cli
|
|
|
46
46
|
## usage
|
|
47
47
|
|
|
48
48
|
```
|
|
49
|
-
usage: familiar [-h] {conjure,invoke,list} ...
|
|
49
|
+
usage: familiar [-h] [--version] [--debug] {conjure,invoke,list,lint} ...
|
|
50
50
|
|
|
51
51
|
conjure and invoke familiars
|
|
52
52
|
|
|
53
53
|
positional arguments:
|
|
54
|
-
{conjure,invoke,list}
|
|
54
|
+
{conjure,invoke,list,lint}
|
|
55
55
|
conjure compose system instructions for an agent
|
|
56
56
|
invoke render an invocation and run the agent
|
|
57
|
-
list list available conjurings
|
|
57
|
+
list list available conjurings and invocations
|
|
58
|
+
lint lint conjurings and invocations
|
|
58
59
|
|
|
59
60
|
options:
|
|
60
61
|
-h, --help show this help message and exit
|
|
62
|
+
--version show program's version number and exit
|
|
63
|
+
--debug show full traceback on error
|
|
61
64
|
```
|
|
62
65
|
|
|
63
66
|
conjure conjurings to create system instructions for an agent:
|
|
@@ -98,6 +101,12 @@ list available conjurings and invocations:
|
|
|
98
101
|
familiar list
|
|
99
102
|
```
|
|
100
103
|
|
|
104
|
+
lint conjurings and invocations, including local `.familiar/` overrides:
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
familiar lint
|
|
108
|
+
```
|
|
109
|
+
|
|
101
110
|
## customization
|
|
102
111
|
|
|
103
112
|
add your own conjurings and invocations by creating files in `.familiar/` in your repo:
|
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from unittest.mock import MagicMock, patch
|
|
6
|
+
|
|
5
7
|
import pytest
|
|
6
|
-
from unittest.mock import patch, MagicMock
|
|
7
8
|
|
|
8
9
|
import familiar.agents as agents_module
|
|
9
10
|
from familiar.agents import (
|
|
10
11
|
Agent,
|
|
11
|
-
CodexAgent,
|
|
12
12
|
ClaudeAgent,
|
|
13
|
-
|
|
14
|
-
get_agents,
|
|
13
|
+
CodexAgent,
|
|
15
14
|
get_agent,
|
|
15
|
+
get_agents,
|
|
16
|
+
load_agents,
|
|
16
17
|
)
|
|
17
18
|
|
|
18
19
|
|