qa-agent-kit 0.6.0__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.
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """Link the QAKit skills into the folder each AI coding tool reads, so one checkout serves them all.
3
+
4
+ python3 tools/install_skills.py --target claude --scope project # <cwd>/.claude/skills/<skill> -> this checkout
5
+ python3 tools/install_skills.py --target all --scope user # ~/.claude/skills, ~/.agents/skills, ~/.cursor/rules, ~/.gemini
6
+ python3 tools/install_skills.py --target codex --scope project --project /path/to/repo
7
+ python3 tools/install_skills.py --target claude --scope project --uninstall
8
+
9
+ Targets and what they get:
10
+ claude .claude/skills/<skill> -> symlink to skills/<skill> (Claude Code loads SKILL.md from there)
11
+ codex .agents/skills/<skill> -> symlink (Codex CLI skills folder); plus AGENTS.md gets a pointer line when the project has one
12
+ cursor .cursor/rules/qakit-<skill>.mdc -> a rule file that tells Cursor to read the SKILL.md
13
+ gemini GEMINI.md (project) or ~/.gemini/GEMINI.md (user) gets a pointer line
14
+ Existing files that are not QAKit links are left alone and reported. Idempotent.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ KIT = Path(__file__).resolve().parents[1]
25
+ SKILLS = sorted(p.parent.name for p in (KIT / "skills").glob("*/SKILL.md"))
26
+ POINTER = "QAKit (QA agent kit): read {path} before QA work; procedures in {skills}.\n"
27
+
28
+
29
+ def targets_for(name: str, scope: str, project: Path, home: Path) -> list[tuple[str, Path, Path | None]]:
30
+ """(kind, destination, source) per skill: kind 'link' symlinks a folder, 'rule' writes a Cursor rule, 'pointer' appends a line."""
31
+ base = project if scope == "project" else home
32
+ out: list[tuple[str, Path, Path | None]] = []
33
+ if name == "claude":
34
+ out += [("link", base / ".claude/skills" / s, KIT / "skills" / s) for s in SKILLS]
35
+ elif name == "codex":
36
+ out += [("link", base / ".agents/skills" / s, KIT / "skills" / s) for s in SKILLS]
37
+ if scope == "project":
38
+ out.append(("pointer", base / "AGENTS.md", None))
39
+ elif name == "cursor":
40
+ out += [("rule", base / ".cursor/rules" / f"qakit-{s}.mdc", KIT / "skills" / s / "SKILL.md") for s in SKILLS]
41
+ elif name == "gemini":
42
+ out.append(("pointer", base / "GEMINI.md" if scope == "project" else home / ".gemini/GEMINI.md", None))
43
+ else:
44
+ raise SystemExit(f"unknown target {name}")
45
+ return out
46
+
47
+
48
+ def rule_text(skill_md: Path) -> str:
49
+ name = skill_md.parent.name
50
+ return f"---\ndescription: QAKit {name} procedure\nalwaysApply: false\n---\nBefore QA work of this kind, read and follow {skill_md} (QAKit, {KIT}).\n"
51
+
52
+
53
+ def pointer_line() -> str:
54
+ return POINTER.format(path=KIT / "AGENTS.md", skills=KIT / "skills")
55
+
56
+
57
+ def apply(kind: str, dest: Path, source: Path | None, uninstall: bool) -> str:
58
+ if kind == "link":
59
+ if dest.is_symlink():
60
+ if os.path.realpath(dest) != os.path.realpath(source):
61
+ return f"skip {dest} (symlink to something else)"
62
+ if uninstall:
63
+ dest.unlink()
64
+ return f"removed {dest}"
65
+ return f"ok {dest}"
66
+ if dest.exists():
67
+ return f"skip {dest} (exists and is not a QAKit link)"
68
+ if uninstall:
69
+ return f"absent {dest}"
70
+ dest.parent.mkdir(parents=True, exist_ok=True)
71
+ dest.symlink_to(source, target_is_directory=True) # mutation-ok: the flag only matters on Windows
72
+ return f"linked {dest} -> {source}"
73
+ if kind == "rule":
74
+ text = rule_text(source)
75
+ if uninstall:
76
+ if dest.is_file() and "QAKit" in dest.read_text(encoding="utf-8"):
77
+ dest.unlink()
78
+ return f"removed {dest}"
79
+ return f"absent {dest}"
80
+ if dest.is_file() and dest.read_text(encoding="utf-8") == text:
81
+ return f"ok {dest}"
82
+ if dest.is_file() and "QAKit" not in dest.read_text(encoding="utf-8"):
83
+ return f"skip {dest} (exists and is not a QAKit rule)"
84
+ dest.parent.mkdir(parents=True, exist_ok=True)
85
+ dest.write_text(text, encoding="utf-8")
86
+ return f"wrote {dest}"
87
+ if kind == "pointer":
88
+ line = pointer_line()
89
+ existing = dest.read_text(encoding="utf-8") if dest.is_file() else ""
90
+ if uninstall:
91
+ if line in existing:
92
+ dest.write_text(existing.replace(line, ""), encoding="utf-8")
93
+ return f"removed pointer in {dest}"
94
+ return f"absent {dest}"
95
+ if line in existing:
96
+ return f"ok {dest}"
97
+ dest.parent.mkdir(parents=True, exist_ok=True)
98
+ dest.write_text(existing + ("\n" if existing and not existing.endswith("\n") else "") + line, encoding="utf-8")
99
+ return f"added pointer in {dest}"
100
+ raise SystemExit(kind)
101
+
102
+
103
+ def main(argv: list[str] | None = None) -> int:
104
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
105
+ parser.add_argument("--target", action="append", choices=["claude", "codex", "cursor", "gemini", "all"], required=True)
106
+ parser.add_argument("--scope", choices=["project", "user"], default="project")
107
+ parser.add_argument("--project", default=".", help="project folder for --scope project (default: cwd)")
108
+ parser.add_argument("--home", default=str(Path.home()), help=argparse.SUPPRESS)
109
+ parser.add_argument("--uninstall", action="store_true")
110
+ args = parser.parse_args(argv)
111
+ names = ["claude", "codex", "cursor", "gemini"] if "all" in args.target else args.target
112
+ project, home = Path(args.project).resolve(), Path(args.home).resolve()
113
+ for name in names:
114
+ for kind, dest, source in targets_for(name, args.scope, project, home):
115
+ print(apply(kind, dest, source, args.uninstall))
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ sys.exit(main()) # mutation-ok: entry point, main() always returns 0