commit b66d72881da022d09185b0a0c5bcbb8e3c5c6d73 Author: spencerm Date: Tue Jun 16 18:14:13 2026 -0600 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5217409 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.env +.DS_Store +# Credentials live in ~/.freshservice/credentials, never in the repo. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6bf9257 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# freshservice-claude + +A [Claude](https://claude.com/claude-code) **skill** for working with +[Freshservice](https://www.freshservice.com/) ITSM from chat / Cowork — view, +create, update, reply to, and triage tickets; look up and update assets/CMDB; +find agents, requesters, groups, and departments; and manage changes, problems, +and releases via the Freshservice REST API v2. + +## Layout + +| File | Purpose | +|------|---------| +| `SKILL.md` | Skill entry point — setup, common commands, guardrails | +| `reference.md` | Endpoints, status/priority codes, filter-query syntax | +| `scripts/fs.py` | Stdlib-only API helper (auth, URL building, paging, errors) | + +## Install + +Copy the skill into your Claude skills directory: + +```bash +cp -R . ~/.claude/skills/freshservice +``` + +## Configure credentials + +```bash +mkdir -p ~/.freshservice +cat > ~/.freshservice/credentials <<'EOF' +FRESHSERVICE_DOMAIN=yourcompany +FRESHSERVICE_API_KEY=your_api_key +EOF +chmod 600 ~/.freshservice/credentials + +python3 ~/.claude/skills/freshservice/scripts/fs.py check +``` + +The API key is in Freshservice → profile picture → *Profile settings* → API key. +`FRESHSERVICE_DOMAIN` accepts the subdomain (`yourcompany`) or the full host. +Environment variables of the same name override the file. + +## Usage + +The skill activates when you mention Freshservice or a ticket/asset/change in +chat. Under the hood it calls: + +```bash +python3 scripts/fs.py request [--param KEY=VALUE ...] [--data ''] +``` + +See `SKILL.md` and `reference.md` for details. diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..b7042c7 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,89 @@ +--- +name: freshservice +description: Work with Freshservice ITSM from chat — view, create, update, reply to, and triage tickets; look up and update assets/CMDB; find agents, requesters, groups, and departments; and manage changes, problems, and releases. Use whenever the user mentions Freshservice, a ticket/incident/service request, an asset or CI, or a change/problem/release in their service desk. +--- + +# Freshservice + +Interact with a Freshservice instance through its REST API v2 using the helper +script `scripts/fs.py`, which handles auth, base URL, query encoding, and +pagination. + +## Setup (first run only) + +The helper needs credentials. Check whether they exist: + +```bash +python3 scripts/fs.py check +``` + +If it reports missing credentials, ask the user for their Freshservice +**domain** (subdomain like `acme`, or full host) and **API key** (found in +Freshservice → profile picture → *Profile settings* → API key). Then write: + +```bash +mkdir -p ~/.freshservice +cat > ~/.freshservice/credentials <<'EOF' +FRESHSERVICE_DOMAIN=acme +FRESHSERVICE_API_KEY=PASTE_KEY_HERE +EOF +chmod 600 ~/.freshservice/credentials +``` + +Re-run `check` to confirm. (Environment variables `FRESHSERVICE_DOMAIN` / +`FRESHSERVICE_API_KEY` override the file if set.) + +## How to call the API + +One command does everything: + +```bash +python3 scripts/fs.py request [--param KEY=VALUE ...] [--data ''] +``` + +- `/api/v2` is added to PATH automatically. +- `--param query=...` and `--param filter=...` are auto-wrapped in the double + quotes the API requires — pass the expression unquoted. +- Output is pretty JSON with `status`, `data`, and `next_page` when more pages exist. + +### Common operations + +```bash +# Tickets +python3 scripts/fs.py request GET /tickets --param per_page=10 --param order_by=updated_at --param order_type=desc +python3 scripts/fs.py request GET /tickets/filter --param 'query=status:2 AND priority:3' +python3 scripts/fs.py request GET /tickets/123 --param include=conversations,requester +python3 scripts/fs.py request POST /tickets --data '{"subject":"Laptop won'\''t boot","description":"...","email":"user@co.com","priority":2,"status":2}' +python3 scripts/fs.py request PUT /tickets/123 --data '{"status":4}' # resolve +python3 scripts/fs.py request POST /tickets/123/reply --data '{"body":"Working on it."}' +python3 scripts/fs.py request POST /tickets/123/notes --data '{"body":"Internal note","private":true}' + +# Assets / CMDB +python3 scripts/fs.py request GET /assets --param 'filter=name:'\''MacBook'\''' +python3 scripts/fs.py request GET /assets/45 --param include=type_fields + +# People +python3 scripts/fs.py request GET /requesters --param email=user@co.com +python3 scripts/fs.py request GET /agents --param email=agent@co.com +python3 scripts/fs.py request GET /groups +python3 scripts/fs.py request GET /departments + +# Change / Problem / Release +python3 scripts/fs.py request GET /changes/filter --param 'query=status:1' +python3 scripts/fs.py request GET /problems +python3 scripts/fs.py request GET /releases +``` + +For endpoints, field names, status/priority codes, and filter-query syntax, read +`reference.md`. + +## Guidance + +- **Confirm before writing.** Before creating/updating tickets/changes or posting + a public `reply` (the requester is emailed), show the user what you'll send and + get a yes — unless they've clearly already authorized it. +- Resolve people to ids first: look up a requester by email before assigning. +- Translate codes for the user (e.g. show "Open / High", not "status 2, priority 3"). +- On a 4xx error, read the `error` body — it names the offending field — and fix + the request rather than retrying verbatim. +- Paginate when `next_page` appears and the user wants the full set. diff --git a/reference.md b/reference.md new file mode 100644 index 0000000..c864ffc --- /dev/null +++ b/reference.md @@ -0,0 +1,75 @@ +# Freshservice API v2 reference + +Base URL: `https://.freshservice.com/api/v2`. Auth is HTTP Basic with the +API key as username (handled by `scripts/fs.py`). Full docs: https://api.freshservice.com + +## Codes + +**Ticket status:** 2 Open · 3 Pending · 4 Resolved · 5 Closed +**Ticket priority:** 1 Low · 2 Medium · 3 High · 4 Urgent +**Ticket source:** 1 Email · 2 Portal · 3 Phone · 4 Chat · 5 Feedback widget · +6 Yammer · 7 AWS CloudWatch · 8 PagerDuty · 9 Walkup · 10 Slack + +Status/priority for changes use their own workflow codes; list a few existing +records to infer them, or read the ticket-fields / change-fields endpoints. + +## Endpoints + +| Resource | List | Create | View | Update | Delete | +|----------|------|--------|------|--------|--------| +| Tickets | `GET /tickets` | `POST /tickets` | `GET /tickets/{id}` | `PUT /tickets/{id}` | `DELETE /tickets/{id}` | +| Ticket filter | `GET /tickets/filter?query="..."` | — | — | — | — | +| Conversations | `GET /tickets/{id}/conversations` | reply: `POST /tickets/{id}/reply`; note: `POST /tickets/{id}/notes` | — | `PUT /conversations/{id}` | `DELETE /conversations/{id}` | +| Assets | `GET /assets` | `POST /assets` | `GET /assets/{display_id}` | `PUT /assets/{display_id}` | `DELETE /assets/{display_id}` | +| Agents | `GET /agents` | `POST /agents` | `GET /agents/{id}` | `PUT /agents/{id}` | — | +| Requesters | `GET /requesters` | `POST /requesters` | `GET /requesters/{id}` | `PUT /requesters/{id}` | — | +| Groups | `GET /groups` | `POST /groups` | `GET /groups/{id}` | `PUT /groups/{id}` | `DELETE /groups/{id}` | +| Departments | `GET /departments` | `POST /departments` | `GET /departments/{id}` | `PUT /departments/{id}` | `DELETE /departments/{id}` | +| Changes | `GET /changes` (+ `/changes/filter`) | `POST /changes` | `GET /changes/{id}` | `PUT /changes/{id}` | `DELETE /changes/{id}` | +| Problems | `GET /problems` | `POST /problems` | `GET /problems/{id}` | `PUT /problems/{id}` | `DELETE /problems/{id}` | +| Releases | `GET /releases` | `POST /releases` | `GET /releases/{id}` | `PUT /releases/{id}` | `DELETE /releases/{id}` | +| Current agent | `GET /agents/me` | — | — | — | — | + +Other useful endpoints reachable the same way: `/solutions/categories`, +`/solutions/folders`, `/solutions/articles`, `/products`, `/vendors`, +`/ticket_fields`, `/tickets/{id}/time_entries`, `/canned_responses`. + +## Ticket fields (create/update) + +`subject`, `description` (HTML ok), `email` or `requester_id`, `priority`, +`status`, `source`, `group_id`, `responder_id` (assigned agent), `type` +(e.g. "Incident", "Service Request"), `tags` (array), `category`, +`sub_category`, `custom_fields` (object), `due_by`, `fr_due_by`. + +Provide **either** `email` **or** `requester_id`. For a public reply the +requester is emailed; a note with `"private": true` is internal-only. + +## Filter (query) syntax — /tickets/filter and /changes/filter + +- Format: `field:value`, combined with `AND` / `OR`. +- Strings single-quoted: `tag:'vip'`. Numbers/dates bare or quoted. +- Operators on dates/numbers: `>`, `<`, e.g. `created_at:>'2026-01-01'`. +- Filterable ticket fields include: `status`, `priority`, `agent_id`, + `group_id`, `requester_id`, `type`, `source`, `tag`, `created_at`, + `updated_at`, `due_by`, plus custom fields by name. +- The whole expression must be URL-double-quoted; `scripts/fs.py` does this when + you pass `--param query=...` / `--param filter=...`. + +Examples: +- `status:2 AND priority:3` +- `agent_id:0 AND status:2` — unassigned open tickets +- `type:'Incident' AND created_at:>'2026-06-01'` + +## Listing vs. filtering + +- `GET /tickets` supports `filter` presets (`new_and_my_open`, `watching`, + `spam`, `deleted`), `updated_since`, `order_by`, `order_type`, `page`, + `per_page` (max 100), and `include` (`conversations`, `requester`, `stats`). +- `GET /tickets/filter?query="..."` is for ad-hoc field queries (also 100/page). + +## Pagination & rate limits + +- 30 results/page default, 100 max via `per_page`. `scripts/fs.py` surfaces + `next_page` when a next-page Link header is present. +- Per-plan minute limits (100–500/min). Responses include + `rate_limit_remaining`. diff --git a/scripts/fs.py b/scripts/fs.py new file mode 100644 index 0000000..080b732 --- /dev/null +++ b/scripts/fs.py @@ -0,0 +1,168 @@ +#!/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: + with urllib.request.urlopen(req) as resp: + 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} + + +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()