From a9b8109b2bbed03aee63cbd3e59b47c78f5e15e4 Mon Sep 17 00:00:00 2001 From: Luke Date: Mon, 9 Feb 2026 02:26:41 +0000 Subject: [PATCH] chore: initialize stef-openclaw-skill with gitea-api skill --- README.md | 31 +++ skills/gitea-api/SKILL.md | 80 ++++++++ skills/gitea-api/scripts/gitea.py | 329 ++++++++++++++++++++++++++++++ skills/gitea-api/scripts/gitea.sh | 5 + 4 files changed, 445 insertions(+) create mode 100644 README.md create mode 100644 skills/gitea-api/SKILL.md create mode 100755 skills/gitea-api/scripts/gitea.py create mode 100755 skills/gitea-api/scripts/gitea.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..7619b2e --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# stef-openclaw-skill + +A curated collection of OpenClaw skills for Stef. + +This repository is organized so an OpenClaw bot can install one or more skills directly from the repo URL/path. + +## Repository Layout + +```text +stef-openclaw-skill/ +├── README.md +└── skills/ + └── gitea-api/ + ├── SKILL.md + └── scripts/ + ├── gitea.py + └── gitea.sh +``` + +## Skills + +| Skill | Purpose | Path | +|---|---|---| +| `gitea-api` | Interact with any Gitea instance via REST API (create repos, issues, PRs, releases, branches, clone) without `tea` CLI. | `skills/gitea-api` | + +## Install Ideas + +- Install the whole repo as a skill source. +- Install a single skill by path from this repo (e.g. `skills/gitea-api`). + +(Exact install command can vary by OpenClaw/ClawHub version.) diff --git a/skills/gitea-api/SKILL.md b/skills/gitea-api/SKILL.md new file mode 100644 index 0000000..410cdc9 --- /dev/null +++ b/skills/gitea-api/SKILL.md @@ -0,0 +1,80 @@ +--- +name: gitea-api +description: Interact with Gitea via REST API (no tea CLI). Use for repo creation, issues, PRs, releases, branches, user info, and cloning on any Gitea instance. +--- + +# Gitea API Skill + +REST-based Gitea control without `tea`. + +## Setup + +Create config file at: +`~/.clawdbot/credentials/gitea/config.json` + +```json +{ + "url": "https://git.fiorinis.com", + "token": "your-personal-access-token" +} +``` + +You can override with env vars: + +```bash +export GITEA_URL="https://git.fiorinis.com" +export GITEA_TOKEN="your-token" +``` + +Token scope should include at least `repo`. + +## Use + +Run via wrapper (works in bash chat commands): + +```bash +bash scripts/gitea.sh [args] +``` + +## Commands + +```bash +# list repos +bash scripts/gitea.sh repos + +# create repo +bash scripts/gitea.sh create --name my-repo --description "My repo" +bash scripts/gitea.sh create --name my-repo --private +bash scripts/gitea.sh create --org Home --name my-repo + +# issues +bash scripts/gitea.sh issues --owner Home --repo my-repo +bash scripts/gitea.sh issue --owner Home --repo my-repo --title "Bug" --body "Details" + +# pull requests +bash scripts/gitea.sh pulls --owner Home --repo my-repo +bash scripts/gitea.sh pr --owner Home --repo my-repo --head feat-1 --base main --title "Add feat" + +# releases +bash scripts/gitea.sh release --owner Home --repo my-repo --tag v1.0.0 --name "Release 1.0.0" + +# branches +bash scripts/gitea.sh branches --owner Home --repo my-repo + +# user info +bash scripts/gitea.sh user # current user +bash scripts/gitea.sh user luke # specific user + +# clone +bash scripts/gitea.sh clone Home/my-repo +bash scripts/gitea.sh clone Home/my-repo ./my-repo +``` + +## Notes + +- Works with Gitea/Forgejo-compatible APIs. +- Config lookup order: + 1. `/home/node/.openclaw/workspace/.clawdbot/credentials/gitea/config.json` + 2. `~/.clawdbot/credentials/gitea/config.json` + 3. env var overrides (`GITEA_URL`, `GITEA_TOKEN`) +- Do not commit tokens. diff --git a/skills/gitea-api/scripts/gitea.py b/skills/gitea-api/scripts/gitea.py new file mode 100755 index 0000000..a4e8f72 --- /dev/null +++ b/skills/gitea-api/scripts/gitea.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""Gitea API Client for OpenClaw (REST, no tea CLI required).""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + +CONFIG_PATHS = [ + "/home/node/.openclaw/workspace/.clawdbot/credentials/gitea/config.json", + os.path.expanduser("~/.clawdbot/credentials/gitea/config.json"), +] + + +def get_config(): + config = {} + + for path in CONFIG_PATHS: + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8") as f: + config = json.load(f) + break + except (OSError, json.JSONDecodeError): + pass + + if os.getenv("GITEA_URL"): + config["url"] = os.getenv("GITEA_URL").rstrip("/") + if os.getenv("GITEA_TOKEN"): + config["token"] = os.getenv("GITEA_TOKEN") + + if "url" not in config: + print("❌ Gitea URL not configured.") + print("Set GITEA_URL or create ~/.clawdbot/credentials/gitea/config.json") + sys.exit(1) + if "token" not in config: + print("❌ Gitea token not configured.") + print("Set GITEA_TOKEN or add token to ~/.clawdbot/credentials/gitea/config.json") + sys.exit(1) + + return config + + +def api_request(endpoint, method="GET", payload=None): + config = get_config() + url = f"{config['url']}/api/v1{endpoint}" + data = json.dumps(payload).encode("utf-8") if payload is not None else None + + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"token {config['token']}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + + try: + with urllib.request.urlopen(req) as resp: + body = resp.read().decode("utf-8") or "{}" + parsed = json.loads(body) + return parsed, resp.status, None + except urllib.error.HTTPError as e: + body = e.read().decode("utf-8") if e.fp else "" + try: + parsed = json.loads(body) if body else {} + msg = parsed.get("message", body) + except Exception: + msg = body or str(e) + return None, e.code, msg + except Exception as e: + return None, None, str(e) + + +def print_api_error(action, status, err): + print(f"❌ Failed to {action}. status={status} error={err}") + + +def cmd_repos(_): + repos, status, err = api_request("/user/repos") + if repos is None: + print_api_error("list repos", status, err) + return 1 + + if not repos: + print("No repositories found.") + return 0 + + print(f"📦 Repositories ({len(repos)}):\n") + for i, repo in enumerate(repos, 1): + private = "🔒" if repo.get("private") else "🌍" + stars = repo.get("stars_count", 0) + desc = (repo.get("description") or "").strip() + print(f"{i}. {private} {repo.get('full_name','?')}") + if desc: + print(f" {desc}") + print(f" ⭐ {stars} Updated: {repo.get('updated_at','')[:19]}") + return 0 + + +def cmd_create(args): + me, status, err = api_request("/user") + if me is None: + print_api_error("get current user", status, err) + return 1 + + owner = args.org or me["login"] + endpoint = f"/org/{owner}/repos" if args.org else "/user/repos" + payload = { + "name": args.name, + "description": args.description or "", + "private": args.private, + "auto_init": True, + } + + repo, status, err = api_request(endpoint, method="POST", payload=payload) + if repo is None: + print_api_error("create repo", status, err) + return 1 + + base = get_config()["url"] + print(f"✅ Repository created: {repo.get('full_name')}") + print(f"URL: {base}/{repo.get('full_name')}") + return 0 + + +def cmd_issues(args): + issues, status, err = api_request(f"/repos/{args.owner}/{args.repo}/issues") + if issues is None: + print_api_error("list issues", status, err) + return 1 + + if not issues: + print("No issues found.") + return 0 + + for issue in issues: + print(f"#{issue['number']} [{issue.get('state')}] {issue.get('title')}") + return 0 + + +def cmd_issue(args): + payload = {"title": args.title, "body": args.body or ""} + issue, status, err = api_request( + f"/repos/{args.owner}/{args.repo}/issues", method="POST", payload=payload + ) + if issue is None: + print_api_error("create issue", status, err) + return 1 + + base = get_config()["url"] + print(f"✅ Issue created: #{issue['number']} - {issue['title']}") + print(f"URL: {base}/{args.owner}/{args.repo}/issues/{issue['number']}") + return 0 + + +def cmd_pulls(args): + pulls, status, err = api_request(f"/repos/{args.owner}/{args.repo}/pulls") + if pulls is None: + print_api_error("list pull requests", status, err) + return 1 + + if not pulls: + print("No pull requests found.") + return 0 + + for pr in pulls: + print(f"#{pr['number']} [{pr.get('state')}] {pr.get('title')}") + return 0 + + +def cmd_pr(args): + payload = { + "title": args.title, + "head": args.head, + "base": args.base, + "body": args.body or "", + } + pr, status, err = api_request( + f"/repos/{args.owner}/{args.repo}/pulls", method="POST", payload=payload + ) + if pr is None: + print_api_error("create pull request", status, err) + return 1 + + base = get_config()["url"] + print(f"✅ Pull request created: #{pr['number']} - {pr['title']}") + print(f"URL: {base}/{args.owner}/{args.repo}/pulls/{pr['number']}") + return 0 + + +def cmd_release(args): + payload = { + "tag_name": args.tag, + "name": args.name or args.tag, + "body": args.body or "", + } + rel, status, err = api_request( + f"/repos/{args.owner}/{args.repo}/releases", method="POST", payload=payload + ) + if rel is None: + print_api_error("create release", status, err) + return 1 + + base = get_config()["url"] + print(f"✅ Release created: {rel.get('tag_name')}") + print(f"URL: {base}/{args.owner}/{args.repo}/releases/tag/{rel.get('tag_name')}") + return 0 + + +def cmd_branches(args): + branches, status, err = api_request(f"/repos/{args.owner}/{args.repo}/branches") + if branches is None: + print_api_error("list branches", status, err) + return 1 + + if not branches: + print("No branches found.") + return 0 + + for b in branches: + sha = (b.get("commit") or {}).get("id", "")[:8] + print(f"{b.get('name')} ({sha})") + return 0 + + +def cmd_user(args): + endpoint = f"/users/{args.username}" if args.username else "/user" + user, status, err = api_request(endpoint) + if user is None: + print_api_error("get user", status, err) + return 1 + + print(f"👤 {user.get('login')}") + print(f"Email: {user.get('email') or 'N/A'}") + print(f"Public repos: {user.get('public_repos_count', 0)}") + return 0 + + +def cmd_clone(args): + base = get_config()["url"] + target = args.dest or "." + url = f"{base}/{args.owner_repo}.git" + print(f"Cloning {url} -> {target}") + rc = subprocess.call(["git", "clone", url, target]) + return rc + + +def build_parser(): + p = argparse.ArgumentParser(description="Gitea REST CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("repos") + + c = sub.add_parser("create") + c.add_argument("--name", required=True) + c.add_argument("--description") + c.add_argument("--private", action="store_true") + c.add_argument("--org") + + i = sub.add_parser("issues") + i.add_argument("--owner", required=True) + i.add_argument("--repo", required=True) + + ic = sub.add_parser("issue") + ic.add_argument("--owner", required=True) + ic.add_argument("--repo", required=True) + ic.add_argument("--title", required=True) + ic.add_argument("--body") + + pls = sub.add_parser("pulls") + pls.add_argument("--owner", required=True) + pls.add_argument("--repo", required=True) + + pr = sub.add_parser("pr") + pr.add_argument("--owner", required=True) + pr.add_argument("--repo", required=True) + pr.add_argument("--head", required=True) + pr.add_argument("--base", required=True) + pr.add_argument("--title", required=True) + pr.add_argument("--body") + + r = sub.add_parser("release") + r.add_argument("--owner", required=True) + r.add_argument("--repo", required=True) + r.add_argument("--tag", required=True) + r.add_argument("--name") + r.add_argument("--body") + + b = sub.add_parser("branches") + b.add_argument("--owner", required=True) + b.add_argument("--repo", required=True) + + u = sub.add_parser("user") + u.add_argument("username", nargs="?") + + cl = sub.add_parser("clone") + cl.add_argument("owner_repo") + cl.add_argument("dest", nargs="?") + + return p + + +def main(): + parser = build_parser() + args = parser.parse_args() + + handlers = { + "repos": cmd_repos, + "create": cmd_create, + "issues": cmd_issues, + "issue": cmd_issue, + "pulls": cmd_pulls, + "pr": cmd_pr, + "release": cmd_release, + "branches": cmd_branches, + "user": cmd_user, + "clone": cmd_clone, + } + raise SystemExit(handlers[args.cmd](args)) + + +if __name__ == "__main__": + main() diff --git a/skills/gitea-api/scripts/gitea.sh b/skills/gitea-api/scripts/gitea.sh new file mode 100755 index 0000000..af48838 --- /dev/null +++ b/skills/gitea-api/scripts/gitea.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python3 "$SCRIPT_DIR/gitea.py" "$@"