Freshservice skill for Claude: tickets, assets, people, changes/problems/releases
Stdlib-only fs.py helper wrapping the Freshservice REST API v2 (auth, URL
building, filter quoting, pagination, error surfacing), plus SKILL.md and a
reference of endpoints, status/priority codes, and filter-query syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:14:13 -06:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Freshservice API helper for the freshservice skill.
|
|
|
|
|
|
|
|
|
|
Handles auth, base URL, query encoding, JSON bodies, and pagination so the
|
|
|
|
|
model only has to choose an HTTP method, a path, and parameters.
|
|
|
|
|
|
|
|
|
|
Credentials (checked in this order):
|
|
|
|
|
1. Environment: FRESHSERVICE_DOMAIN, FRESHSERVICE_API_KEY
|
|
|
|
|
2. File ~/.freshservice/credentials with lines:
|
|
|
|
|
FRESHSERVICE_DOMAIN=acme
|
|
|
|
|
FRESHSERVICE_API_KEY=xxxxxxxx
|
|
|
|
|
FRESHSERVICE_DOMAIN may be the subdomain ("acme") or full host.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
fs.py check
|
|
|
|
|
fs.py request GET /tickets --param per_page=5
|
|
|
|
|
fs.py request GET /tickets/filter --param 'query=status:2 AND priority:3'
|
|
|
|
|
fs.py request GET /tickets/123 --param include=conversations
|
|
|
|
|
fs.py request POST /tickets --data '{"subject":"...","description":"...","email":"a@b.com","priority":2,"status":2}'
|
|
|
|
|
fs.py request PUT /tickets/123 --data '{"status":4}'
|
|
|
|
|
fs.py request POST /tickets/123/reply --data '{"body":"Hi"}'
|
|
|
|
|
|
|
|
|
|
Notes:
|
|
|
|
|
* "/api/v2" is prepended to PATH automatically if absent.
|
|
|
|
|
* For the filter endpoints the API requires the value wrapped in double
|
|
|
|
|
quotes (?query="..."). This helper auto-wraps the `query` and `filter`
|
|
|
|
|
params, so pass them unquoted.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
import base64
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
import urllib.error
|
|
|
|
|
import urllib.parse
|
|
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_credentials() -> tuple[str, str]:
|
|
|
|
|
domain = os.environ.get("FRESHSERVICE_DOMAIN", "").strip()
|
|
|
|
|
api_key = os.environ.get("FRESHSERVICE_API_KEY", "").strip()
|
|
|
|
|
cred_file = os.path.expanduser("~/.freshservice/credentials")
|
|
|
|
|
if (not domain or not api_key) and os.path.exists(cred_file):
|
|
|
|
|
with open(cred_file) as fh:
|
|
|
|
|
for line in fh:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
|
|
|
continue
|
|
|
|
|
k, v = line.split("=", 1)
|
|
|
|
|
k, v = k.strip(), v.strip().strip('"').strip("'")
|
|
|
|
|
if k == "FRESHSERVICE_DOMAIN" and not domain:
|
|
|
|
|
domain = v
|
|
|
|
|
elif k == "FRESHSERVICE_API_KEY" and not api_key:
|
|
|
|
|
api_key = v
|
|
|
|
|
if not domain or not api_key:
|
|
|
|
|
sys.exit(
|
|
|
|
|
"ERROR: Missing credentials. Set FRESHSERVICE_DOMAIN and "
|
|
|
|
|
"FRESHSERVICE_API_KEY in the environment or in "
|
|
|
|
|
"~/.freshservice/credentials (see the skill's SETUP section)."
|
|
|
|
|
)
|
|
|
|
|
if "." not in domain:
|
|
|
|
|
domain = f"{domain}.freshservice.com"
|
|
|
|
|
return domain, api_key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request(method: str, path: str, params: dict[str, str], data: str | None) -> dict:
|
|
|
|
|
domain, api_key = load_credentials()
|
|
|
|
|
path = path if path.startswith("/") else f"/{path}"
|
|
|
|
|
if not path.startswith("/api/"):
|
|
|
|
|
path = f"/api/v2{path}"
|
|
|
|
|
url = f"https://{domain}{path}"
|
|
|
|
|
|
|
|
|
|
# The filter endpoints require the value double-quoted in the URL.
|
|
|
|
|
encoded = {}
|
|
|
|
|
for k, v in params.items():
|
|
|
|
|
if k in ("query", "filter") and not (v.startswith('"') and v.endswith('"')):
|
|
|
|
|
v = f'"{v}"'
|
|
|
|
|
encoded[k] = v
|
|
|
|
|
if encoded:
|
|
|
|
|
url += "?" + urllib.parse.urlencode(encoded)
|
|
|
|
|
|
|
|
|
|
token = base64.b64encode(f"{api_key}:X".encode()).decode()
|
|
|
|
|
headers = {
|
|
|
|
|
"Authorization": f"Basic {token}",
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
}
|
|
|
|
|
body = data.encode() if data else None
|
|
|
|
|
req = urllib.request.Request(url, data=body, method=method.upper(), headers=headers)
|
|
|
|
|
|
|
|
|
|
try:
|
2026-07-08 10:38:56 -06:00
|
|
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
Freshservice skill for Claude: tickets, assets, people, changes/problems/releases
Stdlib-only fs.py helper wrapping the Freshservice REST API v2 (auth, URL
building, filter quoting, pagination, error surfacing), plus SKILL.md and a
reference of endpoints, status/priority codes, and filter-query syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:14:13 -06:00
|
|
|
raw = resp.read().decode()
|
|
|
|
|
out = {"ok": True, "status": resp.status,
|
|
|
|
|
"data": json.loads(raw) if raw else {}}
|
|
|
|
|
link = resp.headers.get("Link")
|
|
|
|
|
if link:
|
|
|
|
|
m = re.search(r'page=(\d+)[^>]*>;\s*rel="next"', link)
|
|
|
|
|
if m:
|
|
|
|
|
out["next_page"] = int(m.group(1))
|
|
|
|
|
rem = resp.headers.get("X-RateLimit-Remaining")
|
|
|
|
|
if rem is not None:
|
|
|
|
|
out["rate_limit_remaining"] = rem
|
|
|
|
|
return out
|
|
|
|
|
except urllib.error.HTTPError as e:
|
|
|
|
|
detail = e.read().decode(errors="replace")
|
|
|
|
|
try:
|
|
|
|
|
detail = json.loads(detail)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
pass
|
|
|
|
|
return {"ok": False, "status": e.code, "error": detail,
|
|
|
|
|
"method": method.upper(), "url": url}
|
|
|
|
|
except urllib.error.URLError as e:
|
|
|
|
|
return {"ok": False, "error": f"Network error: {e.reason}", "url": url}
|
2026-07-08 10:38:56 -06:00
|
|
|
except TimeoutError:
|
|
|
|
|
return {"ok": False, "error": "Request timed out after 30s", "url": url}
|
Freshservice skill for Claude: tickets, assets, people, changes/problems/releases
Stdlib-only fs.py helper wrapping the Freshservice REST API v2 (auth, URL
building, filter quoting, pagination, error surfacing), plus SKILL.md and a
reference of endpoints, status/priority codes, and filter-query syntax.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:14:13 -06:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def main() -> None:
|
|
|
|
|
p = argparse.ArgumentParser(description="Freshservice API helper")
|
|
|
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
|
|
|
|
|
|
pr = sub.add_parser("request", help="Call any API v2 endpoint")
|
|
|
|
|
pr.add_argument("method", help="GET, POST, PUT, DELETE")
|
|
|
|
|
pr.add_argument("path", help='API path, e.g. "/tickets" or "/tickets/123"')
|
|
|
|
|
pr.add_argument("--param", action="append", default=[],
|
|
|
|
|
metavar="KEY=VALUE", help="Query parameter (repeatable)")
|
|
|
|
|
pr.add_argument("--data", help="JSON request body for POST/PUT")
|
|
|
|
|
|
|
|
|
|
sub.add_parser("check", help="Verify credentials and connectivity")
|
|
|
|
|
|
|
|
|
|
args = p.parse_args()
|
|
|
|
|
|
|
|
|
|
if args.cmd == "check":
|
|
|
|
|
res = request("GET", "/agents/me", {}, None)
|
|
|
|
|
if res.get("ok"):
|
|
|
|
|
who = res["data"].get("agent", {})
|
|
|
|
|
print(json.dumps({"ok": True,
|
|
|
|
|
"connected_as": who.get("email") or who.get("first_name"),
|
|
|
|
|
"status": res["status"]}, indent=2))
|
|
|
|
|
else:
|
|
|
|
|
print(json.dumps(res, indent=2, default=str))
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
params = {}
|
|
|
|
|
for item in args.param:
|
|
|
|
|
if "=" not in item:
|
|
|
|
|
sys.exit(f"ERROR: --param must be KEY=VALUE, got: {item}")
|
|
|
|
|
k, v = item.split("=", 1)
|
|
|
|
|
params[k] = v
|
|
|
|
|
|
|
|
|
|
if args.data:
|
|
|
|
|
try:
|
|
|
|
|
json.loads(args.data)
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
sys.exit(f"ERROR: --data is not valid JSON: {e}")
|
|
|
|
|
|
|
|
|
|
res = request(args.method, args.path, params, args.data)
|
|
|
|
|
print(json.dumps(res, indent=2, default=str))
|
|
|
|
|
if not res.get("ok"):
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
main()
|