mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
116 lines
4.5 KiB
Python
116 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Install the dependencies of every portal CLI under .agents/skills/.
|
|
|
|
Run from anywhere: python3 tools/install_portal_clis.py [--dry-run]
|
|
|
|
The CLIs are discovered, not listed: every `.agents/skills/*/cli/package.json`
|
|
is installed, the same glob CI's discover-clis job uses. A portal generated by
|
|
/add-portal or copied from another fork is therefore picked up on a fresh clone
|
|
without anyone editing an install loop, and a portal removed from the tree
|
|
stops being asked for.
|
|
|
|
One command for every shell, since Python is already a prerequisite - the
|
|
README previously carried a PowerShell and a Bash loop, each with its own
|
|
hand-copied list of portal names.
|
|
|
|
A manifest that declares an npm/bun lifecycle script or trustedDependencies is
|
|
skipped and reported instead of installed: those are the hooks that run code
|
|
during `bun install`, and tools/security_guards.py forbids them in-tree. A
|
|
borrowed portal that carries one gets read by a human before it runs.
|
|
|
|
Every install also passes --ignore-scripts, including on older Bun releases
|
|
that run a built-in list of dependency hooks without trustedDependencies.
|
|
|
|
Each CLI installs independently; one failure does not stop the rest. Exit 0
|
|
when every CLI installed, 1 when bun is missing, a manifest was skipped, or an
|
|
install failed.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from security_guards import FORBIDDEN_SCRIPTS # noqa: E402
|
|
|
|
|
|
def discover(root: Path = ROOT) -> list[Path]:
|
|
"""Return each portal CLI directory, sorted by portal name."""
|
|
return sorted(p.parent for p in (root / ".agents" / "skills").glob("*/cli/package.json"))
|
|
|
|
|
|
def unsafe_reason(cli_dir: Path) -> str | None:
|
|
"""Why this manifest must not be installed unattended, or None when it is safe."""
|
|
try:
|
|
data = json.loads((cli_dir / "package.json").read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
return f"unreadable package.json ({exc})"
|
|
if not isinstance(data, dict):
|
|
return "package.json is not a JSON object"
|
|
scripts = data.get("scripts", {})
|
|
bad = sorted(FORBIDDEN_SCRIPTS & set(scripts)) if isinstance(scripts, dict) else []
|
|
if bad:
|
|
return f"declares lifecycle script(s) {bad}"
|
|
if "trustedDependencies" in data:
|
|
return "declares trustedDependencies"
|
|
return None
|
|
|
|
|
|
def install(cli_dirs: list[Path], bun: str, dry_run: bool, root: Path = ROOT, run=subprocess.run) -> int:
|
|
failures = []
|
|
for cli_dir in cli_dirs:
|
|
name = cli_dir.parent.name
|
|
reason = unsafe_reason(cli_dir)
|
|
if reason:
|
|
print(f"SKIP {name}: {reason} - read its code, then run `bun install` in "
|
|
f"{cli_dir.relative_to(root).as_posix()} yourself if you trust it")
|
|
failures.append(name)
|
|
continue
|
|
if dry_run:
|
|
print(f"would install {name}")
|
|
continue
|
|
print(f"installing {name}", flush=True)
|
|
if run([bun, "install", "--ignore-scripts"], cwd=cli_dir).returncode != 0:
|
|
print(f"FAIL {name}: bun install exited non-zero")
|
|
failures.append(name)
|
|
if failures:
|
|
print(f"\n{len(failures)} of {len(cli_dirs)} portal CLI(s) not installed: {', '.join(failures)}")
|
|
return 1
|
|
verb = "would be installed" if dry_run else "installed"
|
|
print(f"\nAll {len(cli_dirs)} portal CLI(s) {verb}.")
|
|
return 0
|
|
|
|
|
|
def _force_utf8_output() -> None:
|
|
"""Write UTF-8 whatever the host's default encoding is (a fork's portal name may not be ASCII)."""
|
|
for stream in (sys.stdout, sys.stderr):
|
|
reconfigure = getattr(stream, "reconfigure", None) # absent on a StringIO under test
|
|
if reconfigure:
|
|
reconfigure(encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
_force_utf8_output()
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--dry-run", action="store_true", help="list what would be installed, run nothing")
|
|
args = ap.parse_args()
|
|
|
|
cli_dirs = discover()
|
|
if not cli_dirs:
|
|
print("No portal CLIs found under .agents/skills/*/cli/ - nothing to install.")
|
|
return 0
|
|
bun = shutil.which("bun")
|
|
if not bun and not args.dry_run:
|
|
print("bun is not on PATH. Install it from https://bun.sh, open a new shell, and re-run.")
|
|
return 1
|
|
return install(cli_dirs, bun or "bun", args.dry_run)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|