diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..1e81c51 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "agentgram", + "interface": { + "displayName": "Agentgram" + }, + "plugins": [ + { + "name": "agentgram", + "source": { + "source": "local", + "path": "./plugins/agentgram" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Productivity" + } + ] +} diff --git a/README.md b/README.md index 4027029..395a719 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,17 @@ The Codex plugin skill lives in `skills/agentgram/SKILL.md`, with the plugin manifest at `.codex-plugin/plugin.json`. The skill tells Codex to use the local `agentgram` CLI as the execution path. +To install Agentgram from the public Codex marketplace file in this repository: + +```sh +codex plugin marketplace add jerryfane/agentgram --ref main +codex plugin add agentgram@agentgram +``` + +Start a new Codex thread after installing so the Agentgram skill is loaded. +Use `codex plugin marketplace upgrade agentgram` before reinstalling when you +want newer Agentgram releases. + When a user asks an agent to send a Telegram message, the agent should: 1. Run `agentgram doctor`, or `bin/agentgram doctor` only from this repository diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 2edc039..36ce355 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -10,6 +10,7 @@ Use this checklist before tagging or announcing an Agentgram release. - Run `git diff --check`. - Run `bin/agentgram update --check` from the release checkout. - Verify `.env.example` contains variable names only. +- Verify `.agents/plugins/marketplace.json` points at `./plugins/agentgram`. - Verify no Telegram tokens, chat ids, generated plugin packages, logs, caches, or local session files are staged. @@ -27,6 +28,18 @@ bin/agentgram doctor `doctor` may fail until `TELEGRAM_BOT_TOKEN` and `TELEGRAM_CHAT_ID` are set; it should fail clearly without printing secret values. +## Codex Marketplace Smoke + +After merging the release, verify Codex can discover the marketplace: + +```sh +codex plugin marketplace add jerryfane/agentgram --ref main +codex plugin list +codex plugin add agentgram@agentgram +``` + +Start a new Codex thread after installing so updated skills are loaded. + ## Optional Live Telegram Smoke Only run this with explicit test credentials from the user: diff --git a/plugins/agentgram/.codex-plugin/plugin.json b/plugins/agentgram/.codex-plugin/plugin.json new file mode 100644 index 0000000..58d8536 --- /dev/null +++ b/plugins/agentgram/.codex-plugin/plugin.json @@ -0,0 +1,33 @@ +{ + "name": "agentgram", + "version": "0.1.0", + "description": "Agent-neutral Telegram messaging helper for local coding agents.", + "author": { + "name": "Jerry Fane", + "url": "https://github.com/jerryfane" + }, + "repository": "https://github.com/jerryfane/agentgram", + "license": "MIT", + "keywords": [ + "telegram", + "agents", + "plugin", + "notifications", + "bot" + ], + "skills": "./skills/", + "interface": { + "displayName": "Agentgram", + "shortDescription": "Send Telegram messages from agent sessions.", + "longDescription": "Agentgram helps coding agents send explicit, user-requested Telegram messages through the local Agentgram CLI and Telegram Bot API using a bot token and chat id from the user's environment.", + "developerName": "Jerry Fane", + "category": "Productivity", + "capabilities": [ + "Read", + "Write" + ], + "defaultPrompt": [ + "Use Agentgram to send a Telegram message." + ] + } +} diff --git a/plugins/agentgram/LICENSE b/plugins/agentgram/LICENSE new file mode 100644 index 0000000..8c535a4 --- /dev/null +++ b/plugins/agentgram/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Jerry Fane + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/agentgram/README.md b/plugins/agentgram/README.md new file mode 100644 index 0000000..395a719 --- /dev/null +++ b/plugins/agentgram/README.md @@ -0,0 +1,131 @@ +# Agentgram + +Agentgram is an agent-neutral Telegram messaging helper. It lets Codex and +other local coding agents send explicit, user-requested Telegram messages +through a Telegram bot token and chat id. + +Agentgram is intentionally local-first. It does not run a hosted service, and it +does not send automatic completion notifications unless a future task explicitly +adds that behavior. + +## Requirements + +- Python 3.12 or newer. +- A Telegram bot token from BotFather. +- A Telegram chat where the bot has been started or added. + +## Configuration + +Set secrets in your shell or agent runtime environment: + +```sh +export TELEGRAM_BOT_TOKEN="123456:bot-token" +export TELEGRAM_CHAT_ID="123456789" +``` + +Do not put real tokens in tracked files. `.env` and `.env.*` are ignored for +local use, but environment variables are the preferred setup. + +For local setup templates, copy [.env.example](.env.example). It contains +variable names only. + +## Usage + +Install from a git checkout, then put the CLI on your `PATH`: + +```sh +git clone https://github.com/jerryfane/agentgram.git ~/.agentgram/agentgram +mkdir -p ~/.local/bin +ln -sf ~/.agentgram/agentgram/bin/agentgram ~/.local/bin/agentgram +``` + +Run the local CLI: + +```sh +agentgram doctor +agentgram send "deploy finished" +agentgram send --silent --no-preview "quiet update" +agentgram send --parse-mode HTML "deploy finished" +``` + +To discover a chat id, first send a message to the bot in Telegram, then run: + +```sh +agentgram chat-id +``` + +For raw Telegram `getUpdates` output: + +```sh +agentgram chat-id --raw +``` + +To check whether the local git checkout is current using existing local refs, or +to update with a fast-forward-only pull: + +```sh +agentgram update --check +agentgram update +``` + +`agentgram update` refuses dirty worktrees, validates the checkout after pulling, +and prints runtime-specific next steps when it can detect them. Codex plugin +users should reinstall or refresh the plugin and start a new thread so updated +skills are loaded. + +## Codex Plugin + +The Codex plugin skill lives in `skills/agentgram/SKILL.md`, with the plugin +manifest at `.codex-plugin/plugin.json`. The skill tells Codex to use the local +`agentgram` CLI as the execution path. + +To install Agentgram from the public Codex marketplace file in this repository: + +```sh +codex plugin marketplace add jerryfane/agentgram --ref main +codex plugin add agentgram@agentgram +``` + +Start a new Codex thread after installing so the Agentgram skill is loaded. +Use `codex plugin marketplace upgrade agentgram` before reinstalling when you +want newer Agentgram releases. + +When a user asks an agent to send a Telegram message, the agent should: + +1. Run `agentgram doctor`, or `bin/agentgram doctor` only from this repository + checkout. +2. Run `agentgram send "message"`, or `bin/agentgram send "message"` only from + this repository checkout, if setup is valid. +3. Avoid direct Telegram API calls unless the user explicitly asks to bypass the + Agentgram CLI. + +## Troubleshooting + +- Bot was not started: open Telegram, send any message to the bot, then run + `agentgram chat-id` again. +- Bad token: run `agentgram doctor`; malformed tokens fail locally, and revoked + or wrong tokens fail the Telegram `getMe` check. +- Missing chat id: set `TELEGRAM_CHAT_ID`, or pass `--chat-id ` for a + one-off send after the user provides the target chat. +- Forbidden chat: add the bot to the target chat or start a private chat with + it, then retry after confirming the chat id. +- Telegram API errors: Agentgram prints Telegram's error description without the + bot token. Re-run `agentgram doctor` before retrying. + +## Release Checks + +Before release, run: + +```sh +python3 -m unittest discover -s tests -v +python3 scripts/validate_manifest.py +git diff --check +``` + +See [docs/release-checklist.md](docs/release-checklist.md) for the full +checklist and fresh-clone smoke. + +## Status + +Pre-release. CLI core, Codex skill packaging, update ergonomics, release docs, +and CI checks are implemented. diff --git a/plugins/agentgram/bin/agentgram b/plugins/agentgram/bin/agentgram new file mode 100755 index 0000000..4860eb1 --- /dev/null +++ b/plugins/agentgram/bin/agentgram @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Agentgram command entry point.""" + +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from agentgram.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/agentgram/skills/agentgram/SKILL.md b/plugins/agentgram/skills/agentgram/SKILL.md new file mode 100644 index 0000000..76fa5f4 --- /dev/null +++ b/plugins/agentgram/skills/agentgram/SKILL.md @@ -0,0 +1,72 @@ +--- +name: agentgram +description: Send explicit, user-requested Telegram messages from an agent session through the local Agentgram command-line tool. +--- + +# Agentgram + +Agentgram is a small Telegram messaging helper for agents. Use this skill when +the user asks to send a Telegram message, verify Telegram messaging setup, find +a chat id, or update the local Agentgram install. + +Before sending messages, prefer the installed `agentgram` command. If it is not +on `PATH` and Agentgram is installed as a Codex plugin, resolve the plugin root +from this skill file at `/skills/agentgram/SKILL.md` and use +`/bin/agentgram` (`../../bin/agentgram` relative to this file). Use +`./bin/agentgram` only after verifying the current checkout is Agentgram itself, +with `.codex-plugin/plugin.json` name `agentgram` and +`skills/agentgram/SKILL.md` present. In any other repository, report that +Agentgram is not installed instead of running project-local fallback scripts or +making an ad hoc Telegram API call. + +## Commands + +```sh +agentgram send "message text" +agentgram chat-id +agentgram doctor +agentgram update +agentgram update --check +``` + +## Required Setup + +Agentgram reads: + +- `TELEGRAM_BOT_TOKEN` +- `TELEGRAM_CHAT_ID` + +Secrets must come from environment variables or a user-owned local config file, +never from tracked repository files, chat output, PR bodies, logs, or generated +plugin packages. + +## Send Workflow + +1. Resolve the safe Agentgram command as `AGENTGRAM_CMD`: `agentgram` on + `PATH`, `/bin/agentgram` from an installed Agentgram plugin, or + `./bin/agentgram` only from a verified Agentgram checkout. +2. Run `$AGENTGRAM_CMD doctor` before sending, unless the user explicitly asks + for a best-effort send without preflight. +3. If `doctor` only fails because `TELEGRAM_CHAT_ID` is missing and the user + provided the target chat id for this message, proceed with + `$AGENTGRAM_CMD send --chat-id `. +4. If `doctor` reports missing `TELEGRAM_CHAT_ID` and the user did not provide + a chat id, run `$AGENTGRAM_CMD chat-id` only after the user has messaged the + bot or added it to the target chat. +5. Send only the exact user-requested message with `$AGENTGRAM_CMD send`. +6. Use `--chat-id` only when the user provided a specific override for that + message. +7. Use `--parse-mode HTML` or `--parse-mode MarkdownV2` only when the user asks + for formatted Telegram output or the message clearly requires it. + +Do not send automatic status updates merely because an agent task completed. +Agentgram sends should be explicit and user-requested. + +## Update Workflow + +Use `$AGENTGRAM_CMD update --check` for a read-only status check on git-based +installs. Use `$AGENTGRAM_CMD update` only when the user asks to update +Agentgram. The update command refuses dirty git checkouts, runs +`git pull --ff-only`, validates the local CLI/plugin files, and prints any Codex +refresh instructions it can detect. For Codex marketplace installs, update with +`codex plugin marketplace upgrade` and then reinstall `agentgram@agentgram`. diff --git a/plugins/agentgram/src/agentgram/__init__.py b/plugins/agentgram/src/agentgram/__init__.py new file mode 100644 index 0000000..b09a59e --- /dev/null +++ b/plugins/agentgram/src/agentgram/__init__.py @@ -0,0 +1,5 @@ +"""Agentgram Telegram messaging helpers.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/plugins/agentgram/src/agentgram/cli.py b/plugins/agentgram/src/agentgram/cli.py new file mode 100644 index 0000000..16afb16 --- /dev/null +++ b/plugins/agentgram/src/agentgram/cli.py @@ -0,0 +1,467 @@ +"""Command-line interface for Agentgram.""" + +from __future__ import annotations + +import argparse +from html.parser import HTMLParser +import json +import os +from pathlib import Path +import subprocess +import sys +from typing import Any, Iterable, TextIO +from urllib.parse import urlsplit, urlunsplit + +from . import __version__ +from .telegram import TelegramClient, TelegramError, looks_like_token + + +MAX_TEXT_LENGTH = 4096 +TOKEN_ENV = "TELEGRAM_BOT_TOKEN" +CHAT_ID_ENV = "TELEGRAM_CHAT_ID" +PLUGIN_NAME = "agentgram" + + +class CliError(RuntimeError): + """Raised for user-correctable command errors.""" + + +def main(argv: list[str] | None = None) -> int: + return run(argv, stdout=sys.stdout, stderr=sys.stderr, environ=os.environ) + + +def run( + argv: list[str] | None = None, + *, + stdout: TextIO, + stderr: TextIO, + environ: dict[str, str], +) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + result = args.func(args, stdout=stdout, environ=environ) + except CliError as exc: + print(f"agentgram: {exc}", file=stderr) + return 2 + except TelegramError as exc: + print(f"agentgram: {exc}", file=stderr) + return 1 + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="agentgram", + description="Send explicit Telegram messages from local agent sessions.", + ) + parser.add_argument("--version", action="version", version=f"agentgram {__version__}") + subcommands = parser.add_subparsers(dest="command", required=True) + + send = subcommands.add_parser("send", help="send a Telegram text message") + send.add_argument("--chat-id", help=f"override {CHAT_ID_ENV}") + send.add_argument("--parse-mode", choices=("HTML", "MarkdownV2"), help="Telegram parse mode") + send.add_argument("--silent", action="store_true", help="send without notification sound") + send.add_argument("--no-preview", action="store_true", help="disable link previews") + send.add_argument("text", nargs="+", help="message text") + send.set_defaults(func=cmd_send) + + chat_id = subcommands.add_parser("chat-id", help="show candidate chat ids from recent updates") + chat_id.add_argument("--raw", action="store_true", help="print raw getUpdates JSON") + chat_id.set_defaults(func=cmd_chat_id) + + doctor = subcommands.add_parser("doctor", help="check Agentgram and Telegram configuration") + doctor.add_argument("--json", action="store_true", dest="json_output", help="print JSON") + doctor.set_defaults(func=cmd_doctor) + + update = subcommands.add_parser("update", help="check or update a git-based Agentgram checkout") + update.add_argument("--check", action="store_true", help="only check update status") + update.add_argument("--repo", default=str(repo_root()), help="Agentgram repository path") + update.set_defaults(func=cmd_update) + return parser + + +def cmd_send(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + token = require_env(environ, TOKEN_ENV) + chat_id = args.chat_id or require_env(environ, CHAT_ID_ENV) + text = normalize_text(args.text) + payload = build_send_payload( + chat_id=chat_id, + text=text, + parse_mode=args.parse_mode, + silent=args.silent, + no_preview=args.no_preview, + ) + message = TelegramClient(token).send_message(payload) + message_id = message.get("message_id") + if message_id is None: + print("sent", file=stdout) + else: + print(f"sent message_id={message_id}", file=stdout) + return 0 + + +def cmd_chat_id(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + token = require_env(environ, TOKEN_ENV) + updates = TelegramClient(token).get_updates() + if args.raw: + print(json.dumps(updates, indent=2, sort_keys=True), file=stdout) + return 0 + + candidates = extract_chat_candidates(updates) + if not candidates: + print("No chat ids found. Send a message to the bot, then run this command again.", file=stdout) + return 0 + for candidate in candidates: + title = candidate.get("title") or candidate.get("username") or candidate.get("name") or "(untitled)" + print(f"{candidate['id']}\t{candidate['type']}\t{title}", file=stdout) + return 0 + + +def cmd_doctor(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + checks: list[dict[str, Any]] = [] + token = environ.get(TOKEN_ENV, "").strip() + chat_id = environ.get(CHAT_ID_ENV, "").strip() + root = repo_root() + checks.append(check("bot_token_env", bool(token), f"{TOKEN_ENV} is {'set' if token else 'missing'}")) + checks.append( + check( + "bot_token_shape", + bool(token and looks_like_token(token)), + "token shape looks valid" if token and looks_like_token(token) else "token shape is invalid or unknown", + required=False, + ) + ) + checks.append(check("chat_id_env", bool(chat_id), f"{CHAT_ID_ENV} is {'set' if chat_id else 'missing'}")) + checks.append( + check( + "plugin_manifest", + (root / ".codex-plugin" / "plugin.json").is_file(), + ".codex-plugin/plugin.json present", + required=False, + ) + ) + checks.append( + check( + "skill_file", + (root / "skills" / "agentgram" / "SKILL.md").is_file(), + "skills/agentgram/SKILL.md present", + required=False, + ) + ) + origin = git_origin_url(root) + safe_origin = redact_url_userinfo(origin) + checks.append( + check( + "git_origin", + bool(origin), + f"origin remote is {safe_origin}" if origin else "origin remote is missing or unavailable", + required=False, + ) + ) + + if token: + try: + bot = TelegramClient(token).get_me() + username = bot.get("username") or bot.get("first_name") or "bot" + checks.append(check("telegram_get_me", True, f"authenticated as {username}")) + except TelegramError as exc: + checks.append(check("telegram_get_me", False, str(exc))) + + ok = all(item["ok"] for item in checks if item["required"]) + if args.json_output: + print(json.dumps({"ok": ok, "checks": checks}, indent=2, sort_keys=True), file=stdout) + else: + for item in checks: + status = "ok" if item["ok"] else "fail" + required = "required" if item["required"] else "optional" + print(f"{status}\t{item['name']}\t{required}\t{item['detail']}", file=stdout) + return 0 if ok else 1 + + +def cmd_update(args: argparse.Namespace, *, stdout: TextIO, environ: dict[str, str]) -> int: + del environ + repo = Path(args.repo).expanduser().resolve() + if not (repo / ".git").exists(): + raise CliError(f"{repo} is not a git checkout") + + if args.check: + status = git_update_status(repo) + print(status, file=stdout) + return 0 + + ensure_clean_worktree(repo) + validate_checkout(repo) + print(git_update_status(repo), file=stdout) + pull_result = run_git(repo, "pull", "--ff-only") + if pull_result: + print(pull_result, file=stdout) + validate_checkout(repo) + print("validation ok", file=stdout) + for line in update_next_steps(repo): + print(line, file=stdout) + return 0 + + +def build_send_payload( + *, + chat_id: str, + text: str, + parse_mode: str | None, + silent: bool, + no_preview: bool, +) -> dict[str, Any]: + if not str(chat_id).strip(): + raise CliError("chat id is required") + validate_text(text, parse_mode=parse_mode) + payload: dict[str, Any] = {"chat_id": chat_id, "text": text} + if parse_mode: + payload["parse_mode"] = parse_mode + if silent: + payload["disable_notification"] = True + if no_preview: + payload["link_preview_options"] = {"is_disabled": True} + return payload + + +def normalize_text(parts: Iterable[str]) -> str: + text = " ".join(parts).strip() + validate_text(text, parse_mode=None, enforce_max=False) + return text + + +def validate_text(text: str, *, parse_mode: str | None = None, enforce_max: bool = True) -> None: + if not text: + raise CliError("message text is required") + length = telegram_text_length(text, parse_mode) + if enforce_max and length > MAX_TEXT_LENGTH: + raise CliError(f"message text is too long: {length} characters; maximum is {MAX_TEXT_LENGTH}") + + +def telegram_text_length(text: str, parse_mode: str | None) -> int: + if parse_mode == "HTML": + return len(html_visible_text(text)) + if parse_mode == "MarkdownV2": + return len(markdown_v2_visible_text(text)) + return len(text) + + +class _VisibleHTMLParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + +def html_visible_text(text: str) -> str: + parser = _VisibleHTMLParser() + parser.feed(text) + parser.close() + return "".join(parser.parts) + + +def markdown_v2_visible_text(text: str) -> str: + visible: list[str] = [] + i = 0 + formatting = set("_*[]()~`>#+-=|{}.!") + while i < len(text): + if text.startswith("```", i): + i += 3 + closing = text.find("```", i) + if closing == -1: + visible.append(text[i:]) + break + visible.append(text[i:closing]) + i = closing + 3 + continue + char = text[i] + if char == "[": + label_end = text.find("](", i + 1) + if label_end != -1: + destination_end = text.find(")", label_end + 2) + if destination_end != -1: + visible.append(markdown_v2_visible_text(text[i + 1 : label_end])) + i = destination_end + 1 + continue + if char == "`": + closing = text.find("`", i + 1) + if closing == -1: + i += 1 + continue + visible.append(text[i + 1 : closing]) + i = closing + 1 + continue + if char == "\\" and i + 1 < len(text): + visible.append(text[i + 1]) + i += 2 + continue + if char in formatting: + i += 1 + continue + visible.append(char) + i += 1 + return "".join(visible) + + +def require_env(environ: dict[str, str], name: str) -> str: + value = environ.get(name, "").strip() + if not value: + raise CliError(f"{name} is required") + return value + + +def extract_chat_candidates(updates: list[dict[str, Any]]) -> list[dict[str, str]]: + seen: set[str] = set() + candidates: list[dict[str, str]] = [] + for update in updates: + for key in ("message", "edited_message", "channel_post", "edited_channel_post", "business_message"): + message = update.get(key) + if not isinstance(message, dict): + continue + chat = message.get("chat") + if not isinstance(chat, dict) or "id" not in chat: + continue + chat_id = str(chat["id"]) + if chat_id in seen: + continue + seen.add(chat_id) + name = chat.get("title") or " ".join( + part for part in (chat.get("first_name"), chat.get("last_name")) if part + ) + candidates.append( + { + "id": chat_id, + "type": str(chat.get("type") or "unknown"), + "title": str(chat.get("title") or ""), + "username": str(chat.get("username") or ""), + "name": str(name or ""), + } + ) + return candidates + + +def check(name: str, ok: bool, detail: str, *, required: bool = True) -> dict[str, Any]: + return {"name": name, "ok": ok, "detail": detail, "required": required} + + +def git_update_status(repo: Path) -> str: + branch = run_git(repo, "rev-parse", "--abbrev-ref", "HEAD", allow_fail=True) or "unknown" + upstream = run_git(repo, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}", allow_fail=True) + if not upstream: + return f"{branch}: unknown update state; no upstream configured" + left_right = run_git(repo, "rev-list", "--left-right", "--count", f"{branch}...{upstream}", allow_fail=True) + if not left_right: + return f"{branch}: unknown update state relative to {upstream}" + try: + ahead, behind = [int(part) for part in left_right.split()] + except ValueError: + return f"{branch}: unknown update state relative to {upstream}" + if ahead == 0 and behind == 0: + return f"{branch}: up to date with local ref {upstream}" + return f"{branch}: ahead {ahead}, behind {behind} relative to local ref {upstream}" + + +def ensure_clean_worktree(repo: Path) -> None: + status = run_git(repo, "status", "--porcelain") + if status: + raise CliError("refusing to update because the git worktree has uncommitted changes") + + +def validate_checkout(repo: Path) -> None: + required_files = [ + repo / "bin" / "agentgram", + repo / ".codex-plugin" / "plugin.json", + repo / "skills" / PLUGIN_NAME / "SKILL.md", + repo / "src" / PLUGIN_NAME / "cli.py", + ] + missing = [str(path.relative_to(repo)) for path in required_files if not path.is_file()] + if missing: + raise CliError(f"checkout validation failed; missing {', '.join(missing)}") + + try: + manifest = json.loads((repo / ".codex-plugin" / "plugin.json").read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + raise CliError(f"checkout validation failed; plugin manifest is invalid JSON: {exc}") from exc + if manifest.get("name") != PLUGIN_NAME: + raise CliError(f"checkout validation failed; plugin manifest name is not {PLUGIN_NAME}") + if manifest.get("skills") != "./skills/": + raise CliError("checkout validation failed; plugin manifest skills path is not ./skills/") + + +def update_next_steps(repo: Path) -> list[str]: + steps = [ + "Next steps:", + f"- CLI users: keep using {repo / 'bin' / 'agentgram'} or refresh your PATH/symlink if needed.", + ] + codex_entry = detected_codex_agentgram_entry() + if codex_entry: + steps.extend( + [ + f"- Codex plugin detected: refresh with `codex plugin add {codex_entry}`.", + "- Start a new Codex thread after reinstall so updated skills are loaded.", + ] + ) + else: + steps.append("- Codex users: reinstall or refresh the Agentgram plugin from the marketplace where you added it.") + return steps + + +def detected_codex_agentgram_entry() -> str | None: + try: + proc = subprocess.run( + ["codex", "plugin", "list"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError: + return None + if proc.returncode != 0: + return None + for line in proc.stdout.splitlines(): + fields = line.split() + if not fields: + continue + entry = fields[0] + status = fields[1] if len(fields) > 1 else "" + if entry.startswith(f"{PLUGIN_NAME}@") and status.startswith("installed"): + return entry + return None + + +def git_origin_url(repo: Path) -> str: + return run_git(repo, "remote", "get-url", "origin", allow_fail=True) + + +def redact_url_userinfo(url: str) -> str: + try: + parsed = urlsplit(url) + except ValueError: + return url + if parsed.scheme and "@" in parsed.netloc: + host = parsed.netloc.rsplit("@", 1)[1] + return urlunsplit((parsed.scheme, host, parsed.path, parsed.query, parsed.fragment)) + return url + + +def run_git(repo: Path, *args: str, allow_fail: bool = False) -> str: + proc = subprocess.run( + ["git", *args], + cwd=repo, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if proc.returncode != 0: + if allow_fail: + return "" + raise CliError(proc.stderr.strip() or f"git {' '.join(args)} failed") + return proc.stdout.strip() + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[2] diff --git a/plugins/agentgram/src/agentgram/telegram.py b/plugins/agentgram/src/agentgram/telegram.py new file mode 100644 index 0000000..aace7ee --- /dev/null +++ b/plugins/agentgram/src/agentgram/telegram.py @@ -0,0 +1,89 @@ +"""Small Telegram Bot API client built on the Python standard library.""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +import re +from typing import Any +from urllib import error, request + + +API_ROOT = "https://api.telegram.org" +TOKEN_RE = re.compile(r"^[0-9]+:[A-Za-z0-9_-]{20,}$") + + +class TelegramError(RuntimeError): + """Raised when Telegram returns an error response or cannot be reached.""" + + +@dataclass(frozen=True) +class TelegramClient: + token: str + api_root: str = API_ROOT + timeout: float = 15.0 + + def request(self, method: str, payload: dict[str, Any] | None = None) -> Any: + token = self.token.strip() + if not looks_like_token(token): + raise TelegramError("Telegram bot token shape is invalid") + payload = payload or {} + body = json.dumps(payload).encode("utf-8") + url = f"{self.api_root}/bot{token}/{method}" + req = request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with request.urlopen(req, timeout=self.timeout) as response: + raw = response.read().decode("utf-8") + except error.HTTPError as exc: + raw_error = exc.read().decode("utf-8", errors="replace") + raise TelegramError(redact_token(_telegram_error_message(raw_error), token)) from exc + except error.URLError as exc: + raise TelegramError(redact_token(f"Telegram request failed: {exc.reason}", token)) from exc + + try: + decoded = json.loads(raw) + except json.JSONDecodeError as exc: + raise TelegramError("Telegram returned invalid JSON") from exc + + if not decoded.get("ok"): + description = decoded.get("description") or "Telegram API request failed" + raise TelegramError(redact_token(str(description), token)) + return decoded.get("result") + + def get_me(self) -> dict[str, Any]: + return self.request("getMe", {}) + + def get_updates(self, limit: int = 20) -> list[dict[str, Any]]: + result = self.request("getUpdates", {"limit": limit, "timeout": 0}) + if not isinstance(result, list): + raise TelegramError("Telegram getUpdates returned an unexpected result") + return result + + def send_message(self, payload: dict[str, Any]) -> dict[str, Any]: + result = self.request("sendMessage", payload) + if not isinstance(result, dict): + raise TelegramError("Telegram sendMessage returned an unexpected result") + return result + + +def looks_like_token(value: str) -> bool: + return bool(TOKEN_RE.match(value.strip())) + + +def redact_token(message: str, token: str | None) -> str: + if not token: + return message + return message.replace(token, "") + + +def _telegram_error_message(raw: str) -> str: + try: + decoded = json.loads(raw) + except json.JSONDecodeError: + return raw or "Telegram API request failed" + return str(decoded.get("description") or decoded.get("error_code") or "Telegram API request failed") diff --git a/scripts/validate_manifest.py b/scripts/validate_manifest.py index b9420d7..be12ec9 100644 --- a/scripts/validate_manifest.py +++ b/scripts/validate_manifest.py @@ -14,6 +14,7 @@ def main() -> int: errors: list[str] = [] manifest_path = ROOT / ".codex-plugin" / "plugin.json" + manifest: dict[str, object] = {} if not manifest_path.is_file(): errors.append("missing .codex-plugin/plugin.json") else: @@ -30,6 +31,8 @@ def main() -> int: errors.append("plugin version is required") if not manifest.get("interface", {}).get("defaultPrompt"): errors.append("plugin interface.defaultPrompt is required") + if manifest.get("version") != "0.1.0": + errors.append("plugin version must match the v0.1.0 release") skill_files = sorted((ROOT / "skills").glob("**/SKILL.md")) if skill_files != [ROOT / "skills" / "agentgram" / "SKILL.md"]: @@ -52,6 +55,65 @@ def main() -> int: if "TELEGRAM_BOT_TOKEN" not in text or "TELEGRAM_CHAT_ID" not in text: errors.append(f"{path.relative_to(ROOT)} must document Telegram env vars") + marketplace_path = ROOT / ".agents" / "plugins" / "marketplace.json" + if not marketplace_path.is_file(): + errors.append("missing .agents/plugins/marketplace.json") + else: + try: + marketplace = json.loads(marketplace_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + errors.append(f"invalid .agents/plugins/marketplace.json: {exc}") + else: + if marketplace.get("name") != "agentgram": + errors.append("marketplace name must be agentgram") + plugins = marketplace.get("plugins") + if not isinstance(plugins, list) or len(plugins) != 1: + errors.append("marketplace must expose exactly one plugin") + else: + plugin = plugins[0] + if not isinstance(plugin, dict): + errors.append("marketplace plugin entry must be an object") + else: + if plugin.get("name") != "agentgram": + errors.append("marketplace plugin name must be agentgram") + source = plugin.get("source") + if not isinstance(source, dict): + errors.append("marketplace source must be an object") + else: + if source.get("source") != "local": + errors.append("marketplace source must be local") + if source.get("path") != "./plugins/agentgram": + errors.append("marketplace source path must point at ./plugins/agentgram") + policy = plugin.get("policy") + if not isinstance(policy, dict): + errors.append("marketplace policy must be present") + else: + if policy.get("installation") != "AVAILABLE": + errors.append("marketplace installation policy must be AVAILABLE") + if policy.get("authentication") != "ON_INSTALL": + errors.append("marketplace authentication policy must be ON_INSTALL") + if plugin.get("category") != "Productivity": + errors.append("marketplace category must be Productivity") + + packaged_root = ROOT / "plugins" / "agentgram" + packaged_files = [ + ".codex-plugin/plugin.json", + "LICENSE", + "README.md", + "bin/agentgram", + "src/agentgram/__init__.py", + "src/agentgram/cli.py", + "src/agentgram/telegram.py", + "skills/agentgram/SKILL.md", + ] + for relative in packaged_files: + root_file = ROOT / relative + packaged_file = packaged_root / relative + if not packaged_file.is_file(): + errors.append(f"missing packaged plugin file plugins/agentgram/{relative}") + elif root_file.read_text(encoding="utf-8") != packaged_file.read_text(encoding="utf-8"): + errors.append(f"packaged plugin file plugins/agentgram/{relative} is out of sync") + if errors: for error in errors: print(f"error: {error}", file=sys.stderr) diff --git a/skills/agentgram/SKILL.md b/skills/agentgram/SKILL.md index 138163e..76fa5f4 100644 --- a/skills/agentgram/SKILL.md +++ b/skills/agentgram/SKILL.md @@ -10,8 +10,11 @@ the user asks to send a Telegram message, verify Telegram messaging setup, find a chat id, or update the local Agentgram install. Before sending messages, prefer the installed `agentgram` command. If it is not -on `PATH`, use `./bin/agentgram` only after verifying the current checkout is -Agentgram itself, with `.codex-plugin/plugin.json` name `agentgram` and +on `PATH` and Agentgram is installed as a Codex plugin, resolve the plugin root +from this skill file at `/skills/agentgram/SKILL.md` and use +`/bin/agentgram` (`../../bin/agentgram` relative to this file). Use +`./bin/agentgram` only after verifying the current checkout is Agentgram itself, +with `.codex-plugin/plugin.json` name `agentgram` and `skills/agentgram/SKILL.md` present. In any other repository, report that Agentgram is not installed instead of running project-local fallback scripts or making an ad hoc Telegram API call. @@ -40,7 +43,8 @@ plugin packages. ## Send Workflow 1. Resolve the safe Agentgram command as `AGENTGRAM_CMD`: `agentgram` on - `PATH`, or `./bin/agentgram` only from a verified Agentgram checkout. + `PATH`, `/bin/agentgram` from an installed Agentgram plugin, or + `./bin/agentgram` only from a verified Agentgram checkout. 2. Run `$AGENTGRAM_CMD doctor` before sending, unless the user explicitly asks for a best-effort send without preflight. 3. If `doctor` only fails because `TELEGRAM_CHAT_ID` is missing and the user @@ -60,7 +64,9 @@ Agentgram sends should be explicit and user-requested. ## Update Workflow -Use `$AGENTGRAM_CMD update --check` for a read-only status check. Use -`$AGENTGRAM_CMD update` only when the user asks to update Agentgram. The update -command refuses dirty git checkouts, runs `git pull --ff-only`, validates the -local CLI/plugin files, and prints any Codex refresh instructions it can detect. +Use `$AGENTGRAM_CMD update --check` for a read-only status check on git-based +installs. Use `$AGENTGRAM_CMD update` only when the user asks to update +Agentgram. The update command refuses dirty git checkouts, runs +`git pull --ff-only`, validates the local CLI/plugin files, and prints any Codex +refresh instructions it can detect. For Codex marketplace installs, update with +`codex plugin marketplace upgrade` and then reinstall `agentgram@agentgram`.