#!/usr/bin/env python3 # Copyright (C) 2026 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Fallback installer for the Perfetto AI agents skills bundle. Downloads the `ai-agents` release branch and copies the Perfetto skill it ships (which carries the `trace_processor` wrapper at `bin/` inside the skill) into a target directory. Used by fallback consumers (OpenCode, Antigravity, Pi) and any agent without a native plugin marketplace. The destination skills/ dir may be shared with skills the user installed from elsewhere (e.g. pi's ~/.agents/skills), so the installer touches only the Perfetto skill and prompts before replacing an existing one. Pass --yes to replace without prompting. Python, not a shell script, on purpose: the bundled `trace_processor` wrapper already requires Python 3, so this adds no new dependency and runs unmodified on Linux, macOS and Windows. macOS/Linux: curl -fsSL https://get.perfetto.dev/agents-install | \ python3 - --target Windows: curl.exe -fsSL https://get.perfetto.dev/agents-install | \ python - --target """ import argparse import json import os import shutil import sys import tarfile import tempfile import urllib.request from typing import Optional REPO = 'google/perfetto' DEFAULT_BRANCH = 'ai-agents' # tools/release/roll-prebuilts stamps the rolled release version here so the # installer served from get.perfetto.dev defaults to that release (and a saved # copy keeps reinstalling it). At the sentinel, installs track the live # `ai-agents` branch tip; held there for now so default installs serve the # rebuilt single-skill bundle until the next release re-stamps a pinned # version. DEFAULT_VERSION = '__VERSION__' # Custom ref namespace mapping a release version to its ai-agents bundle commit, # e.g. refs/ai-agent-tag/v56.0 (created by the release pipeline). Kept out of # refs/tags so it does not show up as a normal tag or in the releases UI. VERSION_REF_NS = 'ai-agent-tag' # --agent name -> default --target (relative to the user's home dir). Best # effort; --target always overrides. Antigravity's location is a guess pending # confirmation against a real install. AGENT_TARGETS = { 'claude': '.claude', 'codex': '.codex', 'opencode': os.path.join('.config', 'opencode'), 'antigravity': '.antigravity', 'pi': '.agents', } # The single skill the bundle ships and the only thing this installer manages. SKILL_NAME = 'perfetto' # Agents that discover skills through skills/index.json. We only write that file # for them; other agents load skills//SKILL.md directly and never need it. INDEX_AGENTS = {'opencode'} def err(msg: str) -> None: sys.stderr.write(f'error: {msg}\n') sys.exit(1) def _http_get(url: str) -> bytes: with urllib.request.urlopen(url) as resp: # noqa: S310 (trusted hosts) return resp.read() def resolve_version_to_sha(version: str) -> Optional[str]: """Resolve a release version to its pinned ai-agents bundle commit SHA. Reads refs// via the GitHub API. Custom ref namespaces are not served by raw.githubusercontent or codeload-by-ref, but the git/ref API resolves them in one call. Returns None (with a warning) on any failure, so the caller can fall back to the branch tip. """ ref = f'{VERSION_REF_NS}/{version}' try: data = json.loads( _http_get(f'https://api.github.com/repos/{REPO}/git/ref/{ref}')) except Exception as e: # noqa: BLE001 (best-effort network path) sys.stderr.write(f'warning: could not resolve version {version} ({e}); ' f'falling back to {DEFAULT_BRANCH} tip\n') return None sha = data.get('object', {}).get('sha') if sha: print(f'Resolved {version} to ai-agents commit {sha}') return sha def resolve_ref(ref_arg: str, version_arg: str) -> str: if ref_arg: return ref_arg # An explicit --version wins; otherwise fall back to the stamped default. The # unstamped sentinel means "track the branch tip" (dev checkout). version = version_arg or (DEFAULT_VERSION if DEFAULT_VERSION != '__VERSION__' else '') if version: return resolve_version_to_sha(version) or DEFAULT_BRANCH return DEFAULT_BRANCH def resolve_target(target_arg: str, agent_arg: str) -> str: if target_arg: return os.path.abspath(os.path.expanduser(target_arg)) if not agent_arg: err('--target is required (or pass --agent for a default)') if agent_arg not in AGENT_TARGETS: err(f'unknown --agent: {agent_arg} ' f'(choose from {", ".join(sorted(AGENT_TARGETS))})') return os.path.join(os.path.expanduser('~'), AGENT_TARGETS[agent_arg]) def download_and_extract(ref: str, workdir: str) -> str: """Download the branch tarball and return the extracted root dir.""" url = f'https://codeload.github.com/{REPO}/tar.gz/{ref}' print(f'Downloading {url}') tarball = os.path.join(workdir, 'bundle.tar.gz') try: urllib.request.urlretrieve(url, tarball) # noqa: S310 (trusted host) except Exception as e: # noqa: BLE001 err(f'failed to download {url}: {e}') with tarfile.open(tarball, 'r:gz') as tf: tf.extractall(workdir) # noqa: S202 (trusted archive from GitHub) # The tarball expands into a single top-level "perfetto-" directory. roots = [ d for d in os.listdir(workdir) if d.startswith('perfetto-') and os.path.isdir(os.path.join(workdir, d)) ] if not roots: err('unexpected tarball layout (no perfetto-* directory)') return os.path.join(workdir, roots[0]) def _confirm(question: str) -> Optional[bool]: """Ask a yes/no question on the controlling terminal. The installer is normally run as `curl ... | python3 -`, so sys.stdin is the piped script, not the user — input() would hit EOF. Read from the terminal directly instead. Returns None when there is no terminal to ask (e.g. CI), so the caller can decide how to proceed. """ tty_name = 'CON' if os.name == 'nt' else '/dev/tty' try: with open(tty_name, 'r') as tty_in, open(tty_name, 'w') as tty_out: tty_out.write(f'{question} [y/N] ') tty_out.flush() answer = tty_in.readline().strip().lower() except OSError: return None return answer in ('y', 'yes') def _refresh_index(dst_skills: str) -> None: """Point skills/index.json at the just-installed Perfetto skill. OpenCode discovers skills via this file. The destination skills/ dir may be shared with skills the user installed elsewhere, so we replace only the Perfetto entry and leave any others in place (creating the file if absent). """ index_path = os.path.join(dst_skills, 'index.json') entries = [] if os.path.isfile(index_path): try: with open(index_path) as f: loaded = json.load(f).get('skills', []) if isinstance(loaded, list): entries = [e for e in loaded if isinstance(e, dict)] except (OSError, ValueError): entries = [] files = [] skill_dir = os.path.join(dst_skills, SKILL_NAME) for root, _, fnames in os.walk(skill_dir): for f in fnames: files.append(os.path.relpath(os.path.join(root, f), skill_dir)) merged = [e for e in entries if e.get('name') != SKILL_NAME] merged.append({'name': SKILL_NAME, 'files': sorted(files)}) merged.sort(key=lambda e: e.get('name', '')) with open(index_path, 'w') as f: f.write(json.dumps({'skills': merged}, indent=2) + '\n') def install(extracted: str, target: str, agent: Optional[str], assume_yes: bool) -> None: src_skill = os.path.join(extracted, 'plugins', 'perfetto', 'skills', SKILL_NAME) if not os.path.isdir(src_skill): err(f'branch is missing plugins/perfetto/skills/{SKILL_NAME} ' '(wrong ref?)') os.makedirs(os.path.join(target, 'bin'), exist_ok=True) dst_skills = os.path.join(target, 'skills') os.makedirs(dst_skills, exist_ok=True) # Touch only the Perfetto skill: the destination skills/ dir is often shared # with skills the user installed elsewhere (e.g. pi's ~/.agents/skills), so we # never remove anything but our own. dst_skill = os.path.join(dst_skills, SKILL_NAME) if os.path.exists(dst_skill): if not assume_yes: confirmed = _confirm(f'Replace the existing Perfetto skill at ' f'{dst_skill}?') if confirmed is None: err(f'{dst_skill} already exists and there is no terminal to confirm ' f'the replacement; re-run with --yes to replace it.') if not confirmed: print('Aborted: left the existing Perfetto skill untouched.') return shutil.rmtree(dst_skill) shutil.copytree(src_skill, dst_skill) # index.json is only meaningful for agents that discover skills through it. # Refresh it for those, and for any target that already has one, but never # fabricate it for agents that load SKILL.md directly. if agent in INDEX_AGENTS or os.path.isfile( os.path.join(dst_skills, 'index.json')): _refresh_index(dst_skills) # Convenience copy of the wrapper at /bin so users can put it on # PATH. The canonical copy lives inside the skill (bin/trace_processor), # which is what the skill's $SKILL_ROOT-based instructions use. src_tp = os.path.join(dst_skill, 'bin', 'trace_processor') if not os.path.isfile(src_tp): err(f'branch is missing skills/{SKILL_NAME}/bin/trace_processor ' '(wrong ref?)') dst_tp = os.path.join(target, 'bin', 'trace_processor') shutil.copy(src_tp, dst_tp) os.chmod(dst_tp, 0o755) def print_path_hint(target: str) -> None: bin_dir = os.path.join(target, 'bin') print() print(f'Installed Perfetto AI agents skills into: {target}') print(f' skills: {os.path.join(target, "skills")}') print(f' trace_processor: {os.path.join(bin_dir, "trace_processor")}') print() print('Add the bundled trace_processor to your PATH:') if os.name == 'nt': print(f' $env:PATH = "{bin_dir};$env:PATH"') else: print(f' export PATH="{bin_dir}:$PATH"') def main() -> int: ap = argparse.ArgumentParser( description='Install the Perfetto AI agents skills bundle.') ap.add_argument( '--target', help='Destination directory. Required unless --agent supplies a default.') ap.add_argument( '--agent', help='One of: claude, codex, opencode, antigravity, pi. Fills a default ' '--target when --target is not given.') ap.add_argument( '--version', help='Release version to install (e.g. v56.0); resolves to that ' "release's pinned bundle. Defaults to the version the installer was " 'published with, else the latest ai-agents tip.') ap.add_argument( '--ref', help='Raw git ref to download (branch, tag or SHA). Overrides --version.') ap.add_argument( '-y', '--yes', action='store_true', help='Replace an existing Perfetto skill without prompting (for ' 'non-interactive installs).') args = ap.parse_args() target = resolve_target(args.target, args.agent) ref = resolve_ref(args.ref, args.version) with tempfile.TemporaryDirectory() as workdir: extracted = download_and_extract(ref, workdir) install(extracted, target, args.agent, args.yes) print_path_hint(target) return 0 if __name__ == '__main__': sys.exit(main())