
Byo Proxy
- 35 installs
- 21 repo stars
- Updated August 3, 2026
- starchild-ai-agent/official-skills
Helps with ai & agent building tasks during AI-assisted development.
About
byo-proxy is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- byo-proxy
- AI & Agent Building
- AI-coding skill
Byo Proxy by the numbers
- 35 all-time installs (skills.sh)
- +5 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #8,740 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/official-skills --skill byo-proxyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 35 |
|---|---|
| repo stars | ★ 21 |
| Last updated | August 3, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
byo-proxy — bring-your-own residential proxy
Users supply their own residential proxy account (currently IPRoyal); this skill stores the credentials in the workspace .env, maintains a skill → provider/country binding table, and exposes a tiny Python API that other skills can opt into.
This skill does not run a proxy server and does not intercept any traffic. It is a configuration center plus a URL builder. Other skills only behave differently if they explicitly import from exports.py.
Boundaries vs. existing proxy skills
| Skill | What it does | When |
|---|---|---|
sc-vpn | Internal VPN gateway, 18 fixed countries, no auth | Last resort for geo-blocked requests inside Starchild |
transparent-proxy-maintenance | Maintain the platform's billing proxy plugins | Ops work on sc-proxy |
| `byo-proxy` (this) | Manage user's own residential provider keys + per-skill bindings | User wants residential IPs, paid accounts, country granularity beyond sc-vpn's 18 |
Supported providers
| Provider | Status | Endpoint | Auth |
|---|---|---|---|
| IPRoyal | ✅ supported | geo.iproyal.com:12321 | username + password |
Adding more providers later: drop a new providers/<name>.py adapter and update PROVIDERS in exports.py. See references/iproyal.md for the IPRoyal username parameter format.
Storage
- Credentials →
/data/workspace/.env(same convention aspolymarket,birdeye,
coingecko). Keys: IPROYAL_USERNAME, IPROYAL_PASSWORD.
- Bindings + provider metadata →
/data/workspace/.byo-proxy.json. Edited only
through scripts; agents should not hand-edit.
User workflow
SKILL=/data/workspace/skills/byo-proxy
# 0. One-shot onboarding (recommended for first-time setup) — does 1+3+5 in sequence:
# prompts for credentials only if missing, creates the binding, runs a live test.
python3 $SKILL/scripts/onboard.py web-crawler --provider iproyal --country jp
# 1. Register a provider (interactive — prompts for username/password)
python3 $SKILL/scripts/setup_provider.py iproyal
# 2. List configured providers + bindings
python3 $SKILL/scripts/list_providers.py
# 3. Bind a skill to a provider/country (long-term preference)
python3 $SKILL/scripts/bind_skill.py web-crawler --provider iproyal --country jp
python3 $SKILL/scripts/bind_skill.py web-crawler --provider iproyal --country jp --sticky 30 # 30-min sticky session
# 4. Unbind
python3 $SKILL/scripts/bind_skill.py web-crawler --unset
# 5. Verify exit IP/country actually works
python3 $SKILL/scripts/test_proxy.py iproyal --country jpHow other skills consume it
Two patterns. Both raise ProxyNotConfiguredError on misconfiguration — never silent fallback (a residential-proxy user is debugging a geo problem; silently falling through to direct connection makes that debugging much harder).
Pattern A — explicit, one-off
Use when a skill needs a specific country for a specific request:
import sys, requests
sys.path.insert(0, "/data/workspace/skills/byo-proxy")
from exports import get_proxy_url
p = get_proxy_url(provider="iproyal", country="jp")
r = requests.get("https://example.com", proxies={"http": p, "https": p}, timeout=30)Pattern B — bound, long-term
Use when a skill always wants to route through whatever the user configured for it:
import sys, requests
sys.path.insert(0, "/data/workspace/skills/byo-proxy")
from exports import get_proxy_for_skill, ProxyNotConfiguredError
try:
p = get_proxy_for_skill("web-crawler") # caller declares its own name
except ProxyNotConfiguredError as e:
# The exception message IS a multi-line onboarding guide for the user —
# surface it verbatim. It includes the signup URL, pricing note, and the
# one-shot `onboard.py` command to fix the situation.
raise SystemExit(str(e))
r = requests.get(url, proxies={"http": p, "https": p}, timeout=30)A skill that does not import get_proxy_for_skill() is unaffected, even if the user has bindings configured. Opt-in only.
Onboarding for unconfigured skills
When get_proxy_for_skill("X") raises because nothing is configured for X, the exception message is already a complete onboarding script: signup URL, pricing, credential location, and the exact onboard.py command to run. Agents that surface this error to a user will naturally walk them through registration and binding — no extra logic needed in the calling skill.
If a calling skill wants to render its own onboarding UI (instead of relying on the error message), it can import the same text directly:
from exports import onboarding_guide
print(onboarding_guide("web-crawler", provider="iproyal", country="jp"))Public API (exports.py)
| Function | Returns | Raises |
|---|---|---|
get_proxy_url(provider, country, sticky_minutes=None, session=None) | str proxy URL | ProxyNotConfiguredError if creds missing or country invalid |
get_proxy_for_skill(skill_name) | str proxy URL | ProxyNotConfiguredError (multi-line onboarding guide) if no binding, binding's provider has no creds, or country invalid |
onboarding_guide(skill_name, provider="iproyal", country="<cc>") | str onboarding text (signup URL, pricing, command) | ValueError on bad provider |
list_providers() | list[dict] — {provider, configured, default_country, bound_skills} | never |
set_binding(skill_name, provider, country, sticky_minutes=None) | None | ValueError on bad provider/country |
unset_binding(skill_name) | None | never |
test_proxy(provider, country) | dict — {ok, exit_ip, geo_country, latency_ms} | ProxyNotConfiguredError |
Per-request only — no global proxy
Same rule as sc-vpn: never export HTTP_PROXY=... from this skill's URLs. Pass proxies= to the specific request only. Setting global env vars will break unrelated skills (notably sc-proxy traffic for paid APIs).
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
ProxyNotConfiguredError: Skill 'X' has no proxy binding | bindings.json has no entry for X | one-shot: python3 scripts/onboard.py X --provider iproyal --country <cc> |
ProxyNotConfiguredError: ...USERNAME / ...PASSWORD not found | provider creds were never saved (or removed from .env) | python3 scripts/setup_provider.py iproyal (or re-run onboard.py) |
ProxyNotConfiguredError: ... bound to unknown provider | bindings.json references a provider that no longer exists | python3 scripts/bind_skill.py X --unset then rebind |
ValueError: Unknown country code 'XX' | not in IPRoyal's supported list | see references/iproyal.md for valid codes |
test_proxy returns ok=false | wrong creds, expired account, network | log into IPRoyal dashboard, check balance / re-register |
Files
byo-proxy/
├── SKILL.md
├── exports.py # public API for other skills
├── scripts/
│ ├── onboard.py # one-shot: setup creds (if needed) + bind + test
│ ├── setup_provider.py # interactive credential setup
│ ├── list_providers.py # show configured providers + bindings
│ ├── bind_skill.py # set/unset skill→provider/country binding
│ └── test_proxy.py # verify exit IP via ifconfig.co
└── references/
└── iproyal.md # IPRoyal-specific endpoint + parameter docs"""
byo-proxy — public API for other skills to consume residential proxies.
Two patterns of use:
A. get_proxy_url(provider, country, ...) — explicit one-off
B. get_proxy_for_skill(skill_name) — opt-in long-term binding
Both raise ProxyNotConfiguredError on misconfiguration. Never silent fallback:
a residential-proxy user is debugging a geo problem, and a silent passthrough
would mask exactly the symptom they care about.
Storage:
Credentials -> /data/workspace/.env (shared with other skills)
Bindings -> /data/workspace/.byo-proxy.json (this skill only)
"""
import json
import os
import time
import urllib.request
import urllib.error
from typing import Optional
ENV_FILE = "/data/workspace/.env"
BINDINGS_FILE = "/data/workspace/.byo-proxy.json"
SKILL_DIR = "/data/workspace/skills/byo-proxy" # canonical runtime path used in error messages
# IPRoyal supported ISO-3166-1 alpha-2 country codes (lowercase).
# Source: https://dashboard.iproyal.com/ — residential pool covers 195+ countries;
# this is the curated subset we expose. Add more codes here as users request them.
IPROYAL_COUNTRIES = {
"us", "ca", "mx", "br", "ar", "cl", "co", "pe",
"gb", "de", "fr", "nl", "es", "it", "se", "no", "fi", "dk", "ch", "at", "be", "pl", "ie", "pt", "cz", "ro",
"ru", "ua", "tr",
"jp", "kr", "sg", "hk", "tw", "th", "vn", "id", "my", "ph", "in", "pk",
"au", "nz",
"za", "eg", "ng", "ke",
"ae", "sa", "il",
}
PROVIDERS = {
"iproyal": {
"host": "geo.iproyal.com",
"port": 12321,
"env_user": "IPROYAL_USERNAME",
"env_pass": "IPROYAL_PASSWORD",
"countries": IPROYAL_COUNTRIES,
"signup_url": "https://iproyal.com/residential-proxies/",
"dashboard_url": "https://dashboard.iproyal.com/",
"credential_hint": "IPRoyal dashboard → Residential → Access (NOT your account login)",
"pricing_note": "Pay-as-you-go from $1.75/GB, no monthly minimum",
},
}
class ProxyNotConfiguredError(RuntimeError):
"""Raised when a proxy URL is requested but cannot be built. Message
always includes the exact remediation command for the user to run."""
# ── onboarding guidance (used by error messages and exposed publicly) ───────
def onboarding_guide(skill_name: str, provider: str = "iproyal",
country: str = "<cc>") -> str:
"""Return a multi-line, agent-readable guide for getting `skill_name`
set up with `provider`. Used as the body of ProxyNotConfiguredError when
a binding is missing, and exposed so calling skills/agents can fetch the
same text on demand (e.g. to render their own onboarding UI).
"""
if provider not in PROVIDERS:
raise ValueError(f"Unknown provider {provider!r}")
cfg = PROVIDERS[provider]
placeholder = country == "<cc>"
tail = (
f"\nReplace {country!r} with an ISO-3166-1 alpha-2 code (e.g. us, jp, de, gb)."
if placeholder else ""
)
return (
f"Skill {skill_name!r} has no proxy binding for provider {provider!r}.\n"
f"\n"
f"One-step onboarding:\n"
f" python3 {SKILL_DIR}/scripts/onboard.py {skill_name} --provider {provider} --country {country}\n"
f"\n"
f"What it will walk you through:\n"
f" 1. Sign up at {cfg['signup_url']} ({cfg['pricing_note']})\n"
f" 2. Copy proxy username + password from {cfg['credential_hint']}\n"
f" 3. Save them to {ENV_FILE}\n"
f" 4. Bind {skill_name!r} to {provider}/{country}\n"
f" 5. Verify the exit IP via ifconfig.co"
f"{tail}\n"
f"Country reference: {SKILL_DIR}/references/{provider}.md"
)
def _creds_missing_message(provider: str, skill_name: str = None) -> str:
cfg = PROVIDERS[provider]
who = f"Skill {skill_name!r} is bound to {provider!r} but " if skill_name else ""
article = "an" if provider[0] in "aeiou" else "a"
return (
f"{who}{cfg['env_user']} / {cfg['env_pass']} not found in {ENV_FILE}.\n"
f"\n"
f"Finish the {provider} setup:\n"
f" python3 {SKILL_DIR}/scripts/setup_provider.py {provider}\n"
f"\n"
f"Don't have {article} {provider} account yet? Sign up first: {cfg['signup_url']}\n"
f" ({cfg['pricing_note']})\n"
f" Get proxy credentials from: {cfg['credential_hint']}"
)
# ── env file IO (polymarket-compatible: same file, same format) ─────────────
def _load_env() -> dict:
env = {}
try:
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line and "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
env[k.strip()] = v.strip()
except FileNotFoundError:
pass
return env
def _save_env_var(key: str, value: str) -> None:
lines = []
try:
with open(ENV_FILE) as f:
lines = f.readlines()
except FileNotFoundError:
pass
new_lines, found = [], False
for line in lines:
if line.strip().startswith(f"{key}="):
new_lines.append(f"{key}={value}\n")
found = True
else:
new_lines.append(line)
if not found:
new_lines.append(f"{key}={value}\n")
os.makedirs(os.path.dirname(ENV_FILE), exist_ok=True)
with open(ENV_FILE, "w") as f:
f.writelines(new_lines)
os.environ[key] = value
def _cred(key: str) -> str:
return os.environ.get(key) or _load_env().get(key, "")
# ── bindings IO ─────────────────────────────────────────────────────────────
def _load_bindings() -> dict:
try:
with open(BINDINGS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_bindings(data: dict) -> None:
os.makedirs(os.path.dirname(BINDINGS_FILE), exist_ok=True)
with open(BINDINGS_FILE, "w") as f:
json.dump(data, f, indent=2, sort_keys=True)
f.write("\n")
# ── provider URL builders ───────────────────────────────────────────────────
def _build_iproyal_url(country: str, sticky_minutes: Optional[int],
session: Optional[str]) -> str:
"""IPRoyal uses password-field params separated by `_`.
Format (current): username:password_country-XX[_session-NAME_lifetime-Nm]@host:port
Older format put params in the username field — that now returns 407.
"""
user = _cred("IPROYAL_USERNAME")
pwd = _cred("IPROYAL_PASSWORD")
if not user or not pwd:
raise ProxyNotConfiguredError(_creds_missing_message("iproyal"))
parts = [f"country-{country}"]
if session:
parts.append(f"session-{session}")
if sticky_minutes:
if not 1 <= sticky_minutes <= 1440:
raise ValueError("sticky_minutes must be between 1 and 1440 (IPRoyal limit)")
parts.append(f"lifetime-{sticky_minutes}m")
pwd_field = pwd + "_" + "_".join(parts)
cfg = PROVIDERS["iproyal"]
return f"http://{user}:{pwd_field}@{cfg['host']}:{cfg['port']}"
_BUILDERS = {"iproyal": _build_iproyal_url}
# ── public API ──────────────────────────────────────────────────────────────
def get_proxy_url(provider: str, country: str,
sticky_minutes: Optional[int] = None,
session: Optional[str] = None) -> str:
"""Build a proxy URL for the given provider + country.
Raises ProxyNotConfiguredError if credentials are missing.
Raises ValueError if provider or country is unsupported.
"""
if provider not in PROVIDERS:
raise ValueError(
f"Unknown provider {provider!r}. Supported: {list(PROVIDERS)}"
)
country = country.lower()
if country not in PROVIDERS[provider]["countries"]:
raise ValueError(
f"Unknown country code {country!r} for provider {provider!r}. "
f"See references/{provider}.md for the supported list."
)
return _BUILDERS[provider](country, sticky_minutes, session)
def get_proxy_for_skill(skill_name: str) -> str:
"""Return the proxy URL the user has bound to the given skill.
Caller passes its own skill name (no auto-detection — explicit is safer).
Raises ProxyNotConfiguredError with a multi-line onboarding guide if the
skill is unbound, or if the bound provider is missing credentials.
"""
bindings = _load_bindings()
entry = bindings.get(skill_name) if bindings else None
if not entry:
# No binding: emit the full onboarding flow.
raise ProxyNotConfiguredError(onboarding_guide(skill_name, provider="iproyal"))
provider = entry["provider"]
cfg = PROVIDERS.get(provider)
if cfg is None:
# Binding references a provider we no longer support.
raise ProxyNotConfiguredError(
f"Skill {skill_name!r} is bound to unknown provider {provider!r}.\n"
f"Rebind: python3 {SKILL_DIR}/scripts/bind_skill.py {skill_name} "
f"--provider iproyal --country <cc>\n"
f"Or unbind: python3 {SKILL_DIR}/scripts/bind_skill.py {skill_name} --unset"
)
if not (_cred(cfg["env_user"]) and _cred(cfg["env_pass"])):
# Binding exists but credentials were never saved (or were removed).
raise ProxyNotConfiguredError(_creds_missing_message(provider, skill_name=skill_name))
return get_proxy_url(
provider=provider,
country=entry["country"],
sticky_minutes=entry.get("sticky_minutes"),
session=entry.get("session"),
)
def list_providers() -> list:
"""Snapshot of every known provider, whether it's configured, and which
skills are bound to it. Safe to call without any setup."""
bindings = _load_bindings()
out = []
for name, cfg in PROVIDERS.items():
configured = bool(_cred(cfg["env_user"]) and _cred(cfg["env_pass"]))
bound = sorted(
f"{skill}→{b['country']}"
for skill, b in bindings.items()
if b.get("provider") == name
)
out.append({
"provider": name,
"configured": configured,
"endpoint": f"{cfg['host']}:{cfg['port']}",
"supported_country_count": len(cfg["countries"]),
"bound_skills": bound,
})
return out
def set_binding(skill_name: str, provider: str, country: str,
sticky_minutes: Optional[int] = None,
session: Optional[str] = None) -> None:
"""Persist a skill→provider/country binding. Validates inputs eagerly so
bad bindings never end up in the file."""
if provider not in PROVIDERS:
raise ValueError(f"Unknown provider {provider!r}")
country = country.lower()
if country not in PROVIDERS[provider]["countries"]:
raise ValueError(f"Unknown country code {country!r} for {provider!r}")
if sticky_minutes is not None and not 1 <= sticky_minutes <= 1440:
raise ValueError("sticky_minutes must be 1..1440")
bindings = _load_bindings()
bindings[skill_name] = {
"provider": provider,
"country": country,
}
if sticky_minutes is not None:
bindings[skill_name]["sticky_minutes"] = sticky_minutes
if session is not None:
bindings[skill_name]["session"] = session
_save_bindings(bindings)
def unset_binding(skill_name: str) -> None:
bindings = _load_bindings()
if skill_name in bindings:
del bindings[skill_name]
_save_bindings(bindings)
def save_credentials(provider: str, **kwargs) -> None:
"""Persist provider credentials to /data/workspace/.env.
Example: save_credentials('iproyal', username='u', password='p')
"""
if provider not in PROVIDERS:
raise ValueError(f"Unknown provider {provider!r}")
cfg = PROVIDERS[provider]
if provider == "iproyal":
if "username" not in kwargs or "password" not in kwargs:
raise ValueError("iproyal requires username= and password=")
_save_env_var(cfg["env_user"], kwargs["username"])
_save_env_var(cfg["env_pass"], kwargs["password"])
else: # pragma: no cover — placeholder for future providers
raise NotImplementedError(provider)
def test_proxy(provider: str, country: str, timeout: int = 15) -> dict:
"""Issue a single request to ifconfig.co/json through the proxy and
return {ok, exit_ip, geo_country, latency_ms}. Network errors return
ok=false with an error field; misconfiguration still raises."""
proxy = get_proxy_url(provider=provider, country=country)
handler = urllib.request.ProxyHandler({"http": proxy, "https": proxy})
opener = urllib.request.build_opener(handler)
req = urllib.request.Request(
"https://ifconfig.co/json",
headers={"User-Agent": "byo-proxy/0.1 test"},
)
started = time.monotonic()
try:
with opener.open(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
return {
"ok": True,
"exit_ip": payload.get("ip"),
"geo_country": (payload.get("country_iso") or "").lower(),
"latency_ms": int((time.monotonic() - started) * 1000),
}
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
return {
"ok": False,
"error": f"{type(e).__name__}: {e}",
"latency_ms": int((time.monotonic() - started) * 1000),
}
IPRoyal residential proxy reference
Loaded by byo-proxy only when the user is configuring or debugging IPRoyal. Source of truth: <https://docs.iproyal.com/proxies/residential>.
Endpoint
geo.iproyal.com:12321Single endpoint for all countries. Region selection happens inside the password field.
Historical note: IPRoyal used to put these params in the username field. Starting
2026, that format returns 407 Proxy Authentication Required — params must nowbe appended to the password.
Password parameter format
<password>_country-<cc>[_state-<st>][_city-<city>][_session-<id>][_lifetime-<N>m][_streaming-1]Parameters are joined with _ and follow the literal password.
| Param | Example | Meaning |
|---|---|---|
country-XX | country-jp | ISO-3166-1 alpha-2, lowercase |
state-NAME | state-california | US states only; lowercase, hyphens for spaces |
city-NAME | city-tokyo | Lowercase, hyphens for spaces. Country must be set. |
session-ID | session-abc123 | Pin a logical session. Same id → same IP (within lifetime). |
lifetime-Nm | lifetime-30m | Sticky-session lifetime, 1..1440 minutes. Requires session-. |
streaming-1 | streaming-1 | Optimized for video/audio streaming. |
Without session-, IPRoyal rotates the exit IP on every request — good for scraping, bad for sites that pin you to a session cookie.
Auth
HTTP basic auth in the proxy URL: http://<username>:<password>_<params>@geo.iproyal.com:12321.
Both username and password come from your IPRoyal dashboard: Residential → Access. They are not your account login.
Country coverage (the 50 we expose by default)
Americas: us ca mx br ar cl co pe Europe: gb de fr nl es it se no fi dk ch at be pl ie pt cz ro ru ua tr Asia: jp kr sg hk tw th vn id my ph in pk Oceania: au nz Africa: za eg ng ke Mideast: ae sa il
IPRoyal advertises 195+ countries — extend IPROYAL_COUNTRIES in exports.py if a user needs a code that isn't in this list. Verify with test_proxy.py after adding.
Pricing model (informational)
Pay-as-you-go from $1.75/GB, no monthly minimum (as of 2026-Q1). Billed per byte, not per request, so rotating vs. sticky doesn't change cost — only your traffic shape does.
Verifying connectivity
curl -x "http://USER:PASS_country-jp@geo.iproyal.com:12321" https://ifconfig.co/jsonExpected: country_iso: "JP", ip is a Japanese residential IP.
Common failure modes
| Symptom | Cause | Fix |
|---|---|---|
407 Proxy Authentication Required | wrong username/password, or params still in username field (old format) | re-run setup_provider.py iproyal; if you hand-built the URL, move params to the password field |
502 Bad Gateway | unsupported country code | check the table above |
| Got an IP from the wrong country | IPRoyal pool depleted; nearest country returned | retry, or use a different country |
| Connection succeeds but site still blocks you | residential IP burned for that site | rotate (drop session-) or pick a different country |
| Sticky session changes IP early | session expired (lifetime exceeded) or IPRoyal evicted | shorter requests, or accept rotation |
#!/usr/bin/env python3
"""Bind / unbind a skill to a residential-proxy provider/country.
Examples:
python3 bind_skill.py web-crawler --provider iproyal --country jp
python3 bind_skill.py web-crawler --provider iproyal --country jp --sticky 30
python3 bind_skill.py web-crawler --unset
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from exports import set_binding, unset_binding, BINDINGS_FILE # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description="Bind a skill to a residential-proxy provider/country.")
ap.add_argument("skill", help="Skill name (caller's identifier in get_proxy_for_skill)")
ap.add_argument("--provider", help="Provider name, e.g. iproyal")
ap.add_argument("--country", help="ISO-3166-1 alpha-2 country code, lowercase")
ap.add_argument("--sticky", type=int, default=None,
help="Sticky-session lifetime in minutes (1..1440). Omit for rotating IPs.")
ap.add_argument("--session", default=None,
help="Optional named session id (any short string). Lets multiple bindings share an IP.")
ap.add_argument("--unset", action="store_true", help="Remove the binding for this skill")
args = ap.parse_args()
if args.unset:
unset_binding(args.skill)
print(f"Unbound {args.skill!r}. ({BINDINGS_FILE})")
return 0
if not args.provider or not args.country:
ap.error("--provider and --country are required (or pass --unset)")
try:
set_binding(args.skill, args.provider, args.country,
sticky_minutes=args.sticky, session=args.session)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
sticky_note = f", sticky={args.sticky}m" if args.sticky else ""
session_note = f", session={args.session}" if args.session else ""
print(f"Bound {args.skill!r} → {args.provider}/{args.country}{sticky_note}{session_note}")
print(f" ({BINDINGS_FILE})")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Print configured providers and skill bindings."""
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from exports import list_providers, _load_bindings # noqa: E402
def main() -> int:
providers = list_providers()
bindings = _load_bindings()
print("Providers:")
for p in providers:
status = "✅ configured" if p["configured"] else "⚪ not configured"
print(f" {p['provider']:10s} {status} endpoint={p['endpoint']} "
f"countries={p['supported_country_count']}")
if p["bound_skills"]:
for b in p["bound_skills"]:
print(f" • {b}")
print()
if bindings:
print(f"Bindings ({len(bindings)}):")
for skill, b in sorted(bindings.items()):
extras = []
if b.get("sticky_minutes"):
extras.append(f"sticky={b['sticky_minutes']}m")
if b.get("session"):
extras.append(f"session={b['session']}")
tail = f" [{', '.join(extras)}]" if extras else ""
print(f" {skill} → {b['provider']}/{b['country']}{tail}")
else:
print("Bindings: (none)")
if "--json" in sys.argv:
print()
print(json.dumps({"providers": providers, "bindings": bindings}, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""End-to-end onboarding for a skill that needs a residential proxy.
Walks the user through whichever steps are still missing, in order:
1. (if no creds saved) prompt for provider username/password and save them
2. (if not bound) create the skill -> provider/country binding
3. (always) test the proxy and report exit IP / country
This is the script that ProxyNotConfiguredError messages point at, so
re-running it after a partial failure should always be safe and idempotent.
Usage:
python3 onboard.py web-crawler --provider iproyal --country jp
python3 onboard.py web-crawler --provider iproyal --country jp --sticky 30
"""
import argparse
import getpass
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from exports import ( # noqa: E402
PROVIDERS, _cred, _load_bindings, save_credentials,
set_binding, test_proxy, ENV_FILE, BINDINGS_FILE,
)
def _prompt_credentials(provider: str) -> tuple[str, str]:
cfg = PROVIDERS[provider]
print()
print(f"━━━ Step 1/3 — register {provider} credentials ━━━")
print(f" Don't have an account yet? Sign up: {cfg['signup_url']}")
print(f" ({cfg['pricing_note']})")
print(f" Then copy your proxy creds from: {cfg['credential_hint']}")
print()
username = input(f"{provider} proxy username: ").strip()
if not username:
print("ERROR: username is required", file=sys.stderr)
sys.exit(2)
password = getpass.getpass(f"{provider} proxy password: ").strip()
if not password:
print("ERROR: password is required", file=sys.stderr)
sys.exit(2)
return username, password
def main() -> int:
ap = argparse.ArgumentParser(
description="One-command onboarding: register creds (if needed), bind skill, test."
)
ap.add_argument("skill", help="Skill name that will call get_proxy_for_skill()")
ap.add_argument("--provider", default="iproyal", choices=sorted(PROVIDERS.keys()))
ap.add_argument("--country", required=True, help="ISO-3166-1 alpha-2 code, lowercase")
ap.add_argument("--sticky", type=int, default=None,
help="Sticky-session lifetime in minutes (1..1440). Omit for rotating IPs.")
ap.add_argument("--session", default=None, help="Optional named session id")
ap.add_argument("--non-interactive", action="store_true",
help="Fail instead of prompting if creds are missing")
args = ap.parse_args()
cfg = PROVIDERS[args.provider]
# Step 1 — credentials
has_creds = bool(_cred(cfg["env_user"]) and _cred(cfg["env_pass"]))
if has_creds:
print(f"[1/3] {args.provider} credentials already in {ENV_FILE} ✅")
else:
if args.non_interactive:
print(
f"ERROR: {cfg['env_user']} / {cfg['env_pass']} missing in {ENV_FILE}. "
f"Re-run without --non-interactive to enter them.",
file=sys.stderr,
)
return 2
username, password = _prompt_credentials(args.provider)
save_credentials(args.provider, username=username, password=password)
print(f"[1/3] saved credentials to {ENV_FILE} ✅")
# Step 2 — binding
bindings = _load_bindings()
existing = bindings.get(args.skill)
if existing:
same = (
existing.get("provider") == args.provider
and existing.get("country") == args.country.lower()
and existing.get("sticky_minutes") == args.sticky
and existing.get("session") == args.session
)
if same:
print(f"[2/3] {args.skill!r} already bound to {args.provider}/{args.country} ✅")
else:
print(
f"[2/3] {args.skill!r} is currently bound to "
f"{existing.get('provider')}/{existing.get('country')}; "
f"overwriting with {args.provider}/{args.country}"
)
try:
set_binding(args.skill, args.provider, args.country,
sticky_minutes=args.sticky, session=args.session)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
if not existing:
sticky_note = f", sticky={args.sticky}m" if args.sticky else ""
print(f"[2/3] bound {args.skill!r} → {args.provider}/{args.country}{sticky_note} ✅")
print(f" ({BINDINGS_FILE})")
# Step 3 — verification
print(f"[3/3] testing exit IP through {args.provider}/{args.country} …")
result = test_proxy(args.provider, args.country)
if not result["ok"]:
print(f" FAIL ({result['latency_ms']}ms): {result.get('error')}", file=sys.stderr)
print(
"\nThe binding was saved but the test failed. Common causes:"
"\n • Wrong username/password — re-run: "
f"python3 {os.path.dirname(__file__)}/setup_provider.py {args.provider}"
"\n • IPRoyal account out of credit — check the dashboard"
"\n • Network unreachable — try again from a machine with outbound HTTPS",
file=sys.stderr,
)
return 1
requested = args.country.lower()
geo_match = "✅" if result["geo_country"] == requested else "⚠️ "
print(
f" OK exit_ip={result['exit_ip']} "
f"country={result['geo_country']} (requested {requested}) {geo_match} "
f"latency={result['latency_ms']}ms"
)
if result["geo_country"] != requested:
print(
f" Note: requested {requested!r} but got {result['geo_country']!r}. "
f"IPRoyal may have rotated to a nearby country if the {requested} pool is depleted.",
)
print(f"\nDone. {args.skill!r} can now call get_proxy_for_skill({args.skill!r}).")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Interactive credential setup for a residential-proxy provider.
Usage:
python3 setup_provider.py iproyal
"""
import argparse
import getpass
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from exports import PROVIDERS, save_credentials, ENV_FILE # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser(description="Register a residential-proxy provider.")
ap.add_argument("provider", choices=sorted(PROVIDERS.keys()))
args = ap.parse_args()
if args.provider == "iproyal":
print("IPRoyal residential proxy setup")
print(" Find credentials at: https://dashboard.iproyal.com/ → Residential → Access")
print(" These are NOT your IPRoyal account login — they are the proxy username/password.")
print()
username = input("IPRoyal proxy username: ").strip()
if not username:
print("ERROR: username is required", file=sys.stderr)
return 2
password = getpass.getpass("IPRoyal proxy password: ").strip()
if not password:
print("ERROR: password is required", file=sys.stderr)
return 2
save_credentials("iproyal", username=username, password=password)
print(f"\nSaved to {ENV_FILE}:")
print(" IPROYAL_USERNAME=***")
print(" IPROYAL_PASSWORD=***")
print("\nNext: python3 scripts/test_proxy.py iproyal --country us")
return 0
print(f"Provider {args.provider!r} not implemented yet", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""Issue a single test request through the proxy and report exit IP/country.
Usage:
python3 test_proxy.py iproyal --country jp
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from exports import test_proxy, ProxyNotConfiguredError # noqa: E402
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("provider")
ap.add_argument("--country", required=True)
ap.add_argument("--timeout", type=int, default=15)
args = ap.parse_args()
try:
result = test_proxy(args.provider, args.country, timeout=args.timeout)
except ProxyNotConfiguredError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 2
if not result["ok"]:
print(f"FAIL ({result['latency_ms']}ms): {result.get('error')}", file=sys.stderr)
return 1
requested = args.country.lower()
actual = result["geo_country"]
geo_match = "✅" if actual == requested else "⚠️ "
print(f"OK exit_ip={result['exit_ip']} "
f"country={actual} (requested {requested}) {geo_match} "
f"latency={result['latency_ms']}ms")
if actual != requested:
print(f" Note: requested {requested!r} but got {actual!r}. "
f"IPRoyal may rotate to a nearby country if the requested pool is depleted.",
file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())