familiar-cli 0.0.5__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """familiar - compose and invoke ai agent prompts."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __all__ = ["agents", "render", "cli"]
6
+
7
+ __version__ = version("familiar-cli")
familiar/agents.py ADDED
@@ -0,0 +1,63 @@
1
+ """Agent implementations for familiar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+
9
+
10
+ class Agent(ABC):
11
+ """Base class for AI coding agents."""
12
+
13
+ name: str
14
+ output_file: str
15
+
16
+ @abstractmethod
17
+ def run(self, repo_root: Path, prompt: str, headless: bool) -> int:
18
+ """Run the agent with the given prompt."""
19
+
20
+
21
+ class CodexAgent(Agent):
22
+ name = "codex"
23
+ output_file = "AGENTS.md"
24
+
25
+ def run(self, repo_root: Path, prompt: str, headless: bool) -> int:
26
+ if headless:
27
+ cmd = ["codex", "exec", "-C", str(repo_root), "-"]
28
+ proc = subprocess.run(cmd, input=prompt, text=True)
29
+ return proc.returncode
30
+ else:
31
+ cmd = ["codex", "-C", str(repo_root), prompt]
32
+ return subprocess.call(cmd)
33
+
34
+
35
+ class ClaudeAgent(Agent):
36
+ name = "claude"
37
+ output_file = "CLAUDE.md"
38
+
39
+ def run(self, repo_root: Path, prompt: str, headless: bool) -> int:
40
+ # claude cli doesn't support a working directory flag like codex's -C;
41
+ # it uses cwd automatically, so repo_root is unused here
42
+ if headless:
43
+ cmd = ["claude", "-p", prompt]
44
+ else:
45
+ cmd = ["claude", prompt]
46
+ return subprocess.call(cmd)
47
+
48
+
49
+ AGENTS: dict[str, Agent] = {
50
+ "codex": CodexAgent(),
51
+ "claude": ClaudeAgent(),
52
+ }
53
+
54
+
55
+ def get_agent(name: str) -> Agent:
56
+ """Get an agent by name.
57
+
58
+ Raises:
59
+ KeyError: if the agent name is not recognized.
60
+ """
61
+ if name not in AGENTS:
62
+ raise KeyError(f"unknown agent: {name}")
63
+ return AGENTS[name]
familiar/cli.py ADDED
@@ -0,0 +1,278 @@
1
+ """Command-line interface for familiar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import traceback
9
+ from pathlib import Path
10
+
11
+ from .agents import AGENTS, get_agent
12
+ from .lint import lint_all
13
+ from .render import render_invocation, compose_system, list_items, NotFoundError
14
+
15
+ # exit codes
16
+ EXIT_SUCCESS = 0
17
+ EXIT_ERROR = 1 # general error (agent failed, etc.)
18
+ EXIT_USAGE = 2 # usage error (bad args, missing files, etc.)
19
+
20
+
21
+ class CliError(Exception):
22
+ """CLI error with optional hint."""
23
+
24
+ def __init__(
25
+ self, message: str, hint: str | None = None, exit_code: int = EXIT_USAGE
26
+ ):
27
+ super().__init__(message)
28
+ self.hint = hint
29
+ self.exit_code = exit_code
30
+
31
+
32
+ def find_repo_root(start: Path) -> Path:
33
+ """Find the repository root by looking for .git directory.
34
+
35
+ Walks up from start directory. Falls back to start directory itself
36
+ if no .git is found, allowing use outside of git repositories.
37
+ """
38
+ cur = start.resolve()
39
+ for p in [cur] + list(cur.parents):
40
+ if (p / ".git").exists():
41
+ return p
42
+ return cur
43
+
44
+
45
+ def write_instruction(repo_root: Path, agent_name: str, system: str) -> None:
46
+ try:
47
+ agent = get_agent(agent_name)
48
+ except KeyError as e:
49
+ raise CliError(str(e), hint=f"valid agents: {', '.join(AGENTS.keys())}")
50
+ (repo_root / agent.output_file).write_text(system.strip() + "\n", encoding="utf-8")
51
+
52
+
53
+ def parse_kv(pairs: list[str]) -> dict[str, str]:
54
+ out: dict[str, str] = {}
55
+ for p in pairs:
56
+ if "=" not in p:
57
+ raise CliError(
58
+ f"invalid argument: {p}",
59
+ hint="use key=value format, e.g. --kv name=myproject",
60
+ )
61
+ k, v = p.split("=", 1)
62
+ out[k.strip()] = v.strip()
63
+ return out
64
+
65
+
66
+ def run_agent(repo_root: Path, agent_name: str, prompt: str, headless: bool) -> int:
67
+ try:
68
+ agent = get_agent(agent_name)
69
+ except KeyError as e:
70
+ raise CliError(str(e), hint=f"valid agents: {', '.join(AGENTS.keys())}")
71
+ try:
72
+ return agent.run(repo_root, prompt, headless)
73
+ except FileNotFoundError:
74
+ raise CliError(
75
+ f"{agent_name} not found in PATH",
76
+ hint=f"install {agent_name} or check your PATH environment variable",
77
+ )
78
+
79
+
80
+ def cmd_conjure(args: argparse.Namespace) -> int:
81
+ repo_root = find_repo_root(Path(args.into or os.getcwd()))
82
+ try:
83
+ system = compose_system(repo_root, args.conjurings)
84
+ except NotFoundError as e:
85
+ raise CliError(
86
+ str(e),
87
+ hint="run 'familiar list conjurings' to see available options",
88
+ )
89
+ write_instruction(repo_root, args.agent, system)
90
+ print(f"wrote instructions for {args.agent}")
91
+ return EXIT_SUCCESS
92
+
93
+
94
+ def cmd_invoke(args: argparse.Namespace) -> int:
95
+ repo_root = find_repo_root(Path(args.into or os.getcwd()))
96
+ kv = parse_kv(args.kv or [])
97
+ try:
98
+ prompt = render_invocation(repo_root, args.invocation, args.inv_args or [], kv)
99
+ except NotFoundError as e:
100
+ raise CliError(
101
+ str(e),
102
+ hint="run 'familiar list invocations' to see available options",
103
+ )
104
+ return run_agent(repo_root, args.agent, prompt, headless=args.headless)
105
+
106
+
107
+ def _print_items(
108
+ items: list[tuple[str, str, bool]], verbose: bool, indent: str = ""
109
+ ) -> None:
110
+ for name, first_line, is_local in items:
111
+ marker = " (local)" if is_local else ""
112
+ if verbose:
113
+ print(f"{indent}{name}{marker}: {first_line}")
114
+ else:
115
+ print(f"{indent}{name}{marker}")
116
+
117
+
118
+ def cmd_list(args: argparse.Namespace) -> int:
119
+ repo_root = find_repo_root(Path(args.into or os.getcwd()))
120
+
121
+ if args.kind is None:
122
+ # list both
123
+ conjurings = list_items(repo_root, "templates")
124
+ invocations = list_items(repo_root, "invocations")
125
+ print("conjurings:")
126
+ _print_items(conjurings, args.verbose, indent=" ")
127
+ print("\ninvocations:")
128
+ _print_items(invocations, args.verbose, indent=" ")
129
+ return EXIT_SUCCESS
130
+
131
+ # map CLI names to internal names; "conjurings" is the user-facing term
132
+ # for what are stored internally as "templates"
133
+ kind = "templates" if args.kind == "conjurings" else args.kind
134
+ items = list_items(repo_root, kind)
135
+
136
+ if not items:
137
+ print(f"no {args.kind} found")
138
+ return EXIT_SUCCESS
139
+
140
+ _print_items(items, args.verbose)
141
+ return EXIT_SUCCESS
142
+
143
+
144
+ def cmd_lint(args: argparse.Namespace) -> int:
145
+ repo_root = find_repo_root(Path(args.into or os.getcwd()))
146
+
147
+ messages = lint_all(repo_root)
148
+
149
+ # Filter by level if requested
150
+ if args.errors_only:
151
+ messages = [m for m in messages if m.level == "error"]
152
+
153
+ if not messages:
154
+ print("all checks passed")
155
+ return EXIT_SUCCESS
156
+
157
+ # Group by level for output
158
+ errors = [m for m in messages if m.level == "error"]
159
+ warnings = [m for m in messages if m.level == "warning"]
160
+
161
+ for msg in errors:
162
+ print(msg, file=sys.stderr)
163
+ for msg in warnings:
164
+ print(msg, file=sys.stderr)
165
+
166
+ if errors:
167
+ print(f"\n{len(errors)} error(s), {len(warnings)} warning(s)", file=sys.stderr)
168
+ return EXIT_ERROR
169
+
170
+ print(f"\n{len(warnings)} warning(s)", file=sys.stderr)
171
+ return EXIT_SUCCESS
172
+
173
+
174
+ def main() -> None:
175
+ parser = argparse.ArgumentParser(
176
+ prog="familiar",
177
+ description="conjure and invoke familiars",
178
+ epilog="examples:\n"
179
+ " familiar conjure codex rust sec # create AGENTS.md\n"
180
+ " familiar invoke codex bootstrap-rust # run invocation\n"
181
+ " familiar list # show all options\n"
182
+ " familiar lint # validate prompts\n",
183
+ formatter_class=argparse.RawDescriptionHelpFormatter,
184
+ )
185
+ parser.add_argument(
186
+ "--debug", action="store_true", help="show full traceback on error"
187
+ )
188
+ sub = parser.add_subparsers(dest="command", required=True)
189
+
190
+ agent_choices = list(AGENTS.keys())
191
+
192
+ conjure = sub.add_parser(
193
+ "conjure",
194
+ help="compose system instructions for an agent",
195
+ epilog="example: familiar conjure codex rust infra sec",
196
+ formatter_class=argparse.RawDescriptionHelpFormatter,
197
+ )
198
+ conjure.add_argument("agent", choices=agent_choices)
199
+ conjure.add_argument(
200
+ "conjurings", nargs="+", help="conjuring names, e.g. rust infra sec"
201
+ )
202
+ conjure.add_argument("--into", help="target repo path (default: current directory)")
203
+ conjure.set_defaults(func=cmd_conjure)
204
+
205
+ invoke = sub.add_parser(
206
+ "invoke",
207
+ help="render an invocation and run the agent",
208
+ epilog="example: familiar invoke codex bootstrap-rust myapp bin 1.78 mit",
209
+ formatter_class=argparse.RawDescriptionHelpFormatter,
210
+ )
211
+ invoke.add_argument("agent", choices=agent_choices)
212
+ invoke.add_argument("invocation")
213
+ invoke.add_argument("--into", help="target repo path (default: current directory)")
214
+ invoke.add_argument(
215
+ "--headless", action="store_true", help="run without interactive UI"
216
+ )
217
+ invoke.add_argument("--kv", nargs="*", help="named arguments as key=value pairs")
218
+ invoke.add_argument(
219
+ "inv_args", nargs="*", help="positional arguments for the invocation"
220
+ )
221
+ invoke.set_defaults(func=cmd_invoke)
222
+
223
+ list_cmd = sub.add_parser(
224
+ "list",
225
+ help="list available conjurings and invocations",
226
+ epilog="example: familiar list conjurings -v",
227
+ formatter_class=argparse.RawDescriptionHelpFormatter,
228
+ )
229
+ list_cmd.add_argument(
230
+ "kind",
231
+ nargs="?",
232
+ choices=["conjurings", "invocations"],
233
+ help="what to list (default: both)",
234
+ )
235
+ list_cmd.add_argument(
236
+ "--into", help="target repo path (default: current directory)"
237
+ )
238
+ list_cmd.add_argument(
239
+ "-v", "--verbose", action="store_true", help="show first line of each file"
240
+ )
241
+ list_cmd.set_defaults(func=cmd_list)
242
+
243
+ lint_cmd = sub.add_parser(
244
+ "lint",
245
+ help="lint templates and invocations",
246
+ epilog="example: familiar lint --errors-only",
247
+ formatter_class=argparse.RawDescriptionHelpFormatter,
248
+ )
249
+ lint_cmd.add_argument(
250
+ "--into", help="target repo path (default: current directory)"
251
+ )
252
+ lint_cmd.add_argument(
253
+ "--errors-only", action="store_true", help="show only errors, not warnings"
254
+ )
255
+ lint_cmd.set_defaults(func=cmd_lint)
256
+
257
+ args = parser.parse_args()
258
+
259
+ try:
260
+ rc = args.func(args)
261
+ except CliError as e:
262
+ if args.debug:
263
+ traceback.print_exc()
264
+ print(f"error: {e}", file=sys.stderr)
265
+ if e.hint:
266
+ print(f"hint: {e.hint}", file=sys.stderr)
267
+ raise SystemExit(e.exit_code)
268
+ except Exception as e:
269
+ if args.debug:
270
+ traceback.print_exc()
271
+ print(f"error: {e}", file=sys.stderr)
272
+ raise SystemExit(EXIT_ERROR)
273
+
274
+ raise SystemExit(rc)
275
+
276
+
277
+ if __name__ == "__main__":
278
+ main()
@@ -0,0 +1 @@
1
+ <!-- noop invocation for conjure command -->
@@ -0,0 +1,68 @@
1
+ task: add tests for existing code.
2
+
3
+ ## inputs
4
+
5
+ - $ARGUMENTS (required): file path, function name, class name, or module to test.
6
+
7
+ ## preconditions
8
+
9
+ STOP and ask if:
10
+ - the target is unclear or cannot be located
11
+ - you're unsure what behavior to test
12
+ - the code has no clear contract or expected behavior
13
+
14
+ before starting:
15
+ - read the code to understand its contract
16
+ - check if tests already exist (don't duplicate)
17
+ - identify the testing framework used in the project
18
+
19
+ ## constraints
20
+
21
+ - **match project conventions**: use the same test framework, file locations, and naming patterns.
22
+ - **no behavior changes**: you're adding tests, not fixing bugs (note bugs separately).
23
+ - **minimal mocking**: only mock external dependencies; prefer real objects when possible.
24
+
25
+ ## what to test
26
+
27
+ write tests covering:
28
+ 1. **happy path**: the main success scenario
29
+ 2. **edge case**: boundary conditions, empty inputs, large inputs
30
+ 3. **error case**: invalid inputs, expected failures
31
+
32
+ ## test quality checklist
33
+
34
+ each test should be:
35
+ - [ ] **deterministic**: no flakiness from time, network, or random values
36
+ - [ ] **isolated**: can run independently of other tests
37
+ - [ ] **fast**: no unnecessary I/O or sleeps
38
+ - [ ] **readable**: clear what's being tested and why
39
+ - [ ] **focused**: one behavior per test
40
+
41
+ ## steps
42
+
43
+ 1. **locate**: find the code to test and understand its contract.
44
+ 2. **check**: verify no duplicate tests exist.
45
+ 3. **plan**: list the test cases you will write.
46
+ 4. **write**: implement tests, preferring parametrized/table-driven style.
47
+ 5. **run**: execute the test suite and confirm all tests pass.
48
+
49
+ ## output
50
+
51
+ ```
52
+ ## unit under test
53
+ <file:function or class being tested>
54
+
55
+ ## test cases
56
+ 1. <test name>: <what it verifies>
57
+ 2. <test name>: <what it verifies>
58
+ 3. <test name>: <what it verifies>
59
+
60
+ ## changes
61
+ <diff of new test file or additions>
62
+
63
+ ## verification
64
+ <exact test command>
65
+
66
+ ## notes (optional)
67
+ <bugs noticed, edge cases not covered, etc.>
68
+ ```
@@ -0,0 +1,102 @@
1
+ task: bootstrap a new python project with modern tooling.
2
+
3
+ ## inputs
4
+
5
+ - $1 `package_name` (required): name of the package (e.g., `myapp`)
6
+ - $2 `package_type` (required): `cli` or `lib`
7
+ - $3 `python_version` (optional): minimum python version (default: `3.10`)
8
+ - $4 `license` (optional): license identifier (e.g., `MIT`, `Apache-2.0`)
9
+
10
+ ## preconditions
11
+
12
+ STOP and ask if:
13
+ - package_name is missing or invalid (not a valid python identifier)
14
+ - package_type is not `cli` or `lib`
15
+ - the target directory already exists (ask: abort, overwrite, or integrate?)
16
+
17
+ ## what gets created
18
+
19
+ ```
20
+ <package_name>/
21
+ ├── pyproject.toml # modern python packaging with ruff, mypy, pytest
22
+ ├── README.md # one-line description + quickstart
23
+ ├── src/
24
+ │ └── <package_name>/
25
+ │ ├── __init__.py # version and public API
26
+ │ ├── main.py # (cli) entry point with argument parsing
27
+ │ └── core.py # (lib) core module placeholder
28
+ └── tests/
29
+ ├── __init__.py
30
+ └── test_<package_name>.py # minimal test
31
+ ```
32
+
33
+ ## steps
34
+
35
+ 1. **validate**: confirm inputs are valid and directory doesn't conflict.
36
+ 2. **create**: set up directory structure with src layout.
37
+ 3. **configure**: create pyproject.toml with metadata and tool configs.
38
+ 4. **readme**: create README.md with purpose and quickstart.
39
+ 5. **code**: add minimal implementation with type hints.
40
+ 6. **test**: add a minimal passing test.
41
+ 7. **verify**: run format, lint, type check, and tests.
42
+
43
+ ## pyproject.toml structure
44
+
45
+ ```toml
46
+ [project]
47
+ name = "<package_name>"
48
+ version = "0.1.0"
49
+ description = "TODO: add description"
50
+ requires-python = ">=<python_version>"
51
+ license = "<license>" # if provided
52
+ dependencies = []
53
+
54
+ [project.optional-dependencies]
55
+ dev = ["ruff", "mypy", "pytest", "pytest-cov"]
56
+
57
+ [project.scripts] # cli only
58
+ <package_name> = "<package_name>.main:main"
59
+
60
+ [tool.ruff]
61
+ target-version = "py<python_version_short>"
62
+ line-length = 88
63
+
64
+ [tool.mypy]
65
+ python_version = "<python_version>"
66
+ strict = true
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ ```
71
+
72
+ ## acceptance criteria
73
+
74
+ all must pass:
75
+ ```
76
+ ruff format --check .
77
+ ruff check .
78
+ mypy .
79
+ pytest -q
80
+ ```
81
+
82
+ ## output
83
+
84
+ ```
85
+ ## project created
86
+ <package_name> (<package_type>)
87
+
88
+ ## files
89
+ <list of files created with brief description>
90
+
91
+ ## changes
92
+ <diff of all created files>
93
+
94
+ ## setup
95
+ pip install -e ".[dev]"
96
+
97
+ ## verification
98
+ ruff format --check . && ruff check . && mypy . && pytest -q
99
+
100
+ ## next steps
101
+ <suggested next actions: add dependencies, implement features, publish, etc.>
102
+ ```
@@ -0,0 +1,81 @@
1
+ task: bootstrap a new rust crate with best practices.
2
+
3
+ ## inputs
4
+
5
+ - $1 `crate_name` (required): name of the crate (e.g., `myapp`)
6
+ - $2 `crate_type` (required): `bin` or `lib`
7
+ - $3 `msrv` (optional): minimum supported rust version (e.g., `1.75`)
8
+ - $4 `license` (optional): license identifier (e.g., `MIT`, `Apache-2.0`)
9
+
10
+ ## preconditions
11
+
12
+ STOP and ask if:
13
+ - crate_name is missing or invalid (not a valid rust identifier)
14
+ - crate_type is not `bin` or `lib`
15
+ - the target directory already exists (ask: abort, overwrite, or integrate?)
16
+
17
+ ## what gets created
18
+
19
+ ```
20
+ <crate_name>/
21
+ ├── Cargo.toml # with metadata, msrv, license if provided
22
+ ├── README.md # one-line description + quickstart
23
+ ├── src/
24
+ │ ├── main.rs # (bin) minimal main with error handling
25
+ │ └── lib.rs # (lib) minimal module with doc comment
26
+ └── tests/ # (lib only) integration test placeholder
27
+ └── integration.rs
28
+ ```
29
+
30
+ ## steps
31
+
32
+ 1. **validate**: confirm inputs are valid and directory doesn't conflict.
33
+ 2. **create**: run `cargo new <crate_name> --<crate_type>`.
34
+ 3. **configure**: update Cargo.toml with metadata, msrv, and license.
35
+ 4. **readme**: create README.md with purpose and quickstart.
36
+ 5. **test**: add a minimal test if not present.
37
+ 6. **verify**: run format, clippy, and tests.
38
+
39
+ ## cargo.toml structure
40
+
41
+ ```toml
42
+ [package]
43
+ name = "<crate_name>"
44
+ version = "0.1.0"
45
+ edition = "2021"
46
+ rust-version = "<msrv>" # if provided
47
+ license = "<license>" # if provided
48
+ description = "TODO: add description"
49
+
50
+ [dependencies]
51
+
52
+ [dev-dependencies]
53
+ ```
54
+
55
+ ## acceptance criteria
56
+
57
+ all must pass:
58
+ ```
59
+ cargo fmt --check
60
+ cargo clippy --all-targets --all-features -- -D warnings
61
+ cargo test --all-features
62
+ ```
63
+
64
+ ## output
65
+
66
+ ```
67
+ ## crate created
68
+ <crate_name> (<crate_type>)
69
+
70
+ ## files
71
+ <list of files created with brief description>
72
+
73
+ ## changes
74
+ <diff of all created/modified files>
75
+
76
+ ## verification
77
+ cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features
78
+
79
+ ## next steps
80
+ <suggested next actions: add dependencies, implement features, etc.>
81
+ ```
@@ -0,0 +1,80 @@
1
+ task: review code for quality, correctness, and maintainability.
2
+
3
+ ## inputs
4
+
5
+ - $ARGUMENTS (optional): file path, function name, or scope to review. if empty, review recent changes or staged diff.
6
+
7
+ ## preconditions
8
+
9
+ if no target is specified and no recent changes exist:
10
+ - ask what to review
11
+ - do not review arbitrary code
12
+
13
+ ## review checklist
14
+
15
+ examine the code for:
16
+
17
+ **correctness**
18
+ - does it do what it claims to do?
19
+ - are edge cases handled?
20
+ - are error conditions handled properly?
21
+ - are there potential null/undefined issues?
22
+
23
+ **clarity**
24
+ - is the code easy to understand?
25
+ - are names descriptive and consistent?
26
+ - is the logic straightforward or needlessly complex?
27
+ - are there comments where needed (and only where needed)?
28
+
29
+ **maintainability**
30
+ - is the code modular and testable?
31
+ - does it follow project conventions?
32
+ - would a new team member understand it?
33
+ - is there duplication that should be extracted?
34
+
35
+ **performance** (only if relevant)
36
+ - are there obvious inefficiencies?
37
+ - is there unnecessary work in hot paths?
38
+
39
+ ## how to give feedback
40
+
41
+ - **be specific**: reference file:line, quote the code
42
+ - **be actionable**: say what to change, not just what's wrong
43
+ - **be proportionate**: don't nitpick style in a bugfix review
44
+ - **acknowledge good work**: note well-written code too
45
+
46
+ ## severity levels
47
+
48
+ - **blocker**: must fix before merge (bugs, security issues, data loss risks)
49
+ - **major**: should fix before merge (design issues, missing tests, unclear logic)
50
+ - **minor**: consider fixing (style, naming, minor improvements)
51
+ - **nit**: optional, take or leave (personal preferences)
52
+
53
+ ## output
54
+
55
+ ```
56
+ ## scope
57
+ <what was reviewed: files, functions, commit range>
58
+
59
+ ## summary
60
+ <1-2 sentence overall assessment>
61
+
62
+ ## findings
63
+
64
+ ### blockers
65
+ - [file:line] <issue>
66
+ - suggestion: <how to fix>
67
+
68
+ ### major
69
+ - [file:line] <issue>
70
+ - suggestion: <how to fix>
71
+
72
+ ### minor
73
+ - [file:line] <issue>
74
+
75
+ ### positive
76
+ - <what was done well>
77
+
78
+ ## verdict
79
+ <approve / request changes / needs discussion>
80
+ ```