diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8ed82a5..c933803 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,7 +6,7 @@ }, "metadata": { "description": "TryPost plugin for planning, scheduling and publishing social media posts", - "version": "2.0.0" + "version": "2.0.1" }, "plugins": [ { diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 63f9284..8025658 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trypost", - "version": "2.0.0", + "version": "2.0.1", "description": "Plan, schedule and publish social media posts, manage your queue and approvals, and read post analytics in TryPost.", "author": { "name": "Paulo Castellano", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 643d89c..efae7bf 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trypost", - "version": "2.0.0", + "version": "2.0.1", "description": "Plan, schedule and publish social media posts, manage your queue and approvals, and read post analytics in TryPost.", "author": { "name": "TryPost", @@ -41,6 +41,7 @@ }, "extensions": { "com.openai": { + "onboardingSkill": "./skills/setup/SKILL.md", "review": { "test_cases": { "positive": [ diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json index 9f7993e..e1ac00c 100644 --- a/.cursor-plugin/marketplace.json +++ b/.cursor-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "trypost", "source": "./", - "version": "2.0.0", + "version": "2.0.1", "description": "Plan, schedule and publish social media posts, manage your queue and approvals, and read post analytics in TryPost.", "author": { "name": "Paulo Castellano", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index fd13c0a..b933d7d 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trypost", - "version": "2.0.0", + "version": "2.0.1", "description": "Plan, schedule and publish social media posts, manage your queue and approvals, and read post analytics in TryPost.", "author": { "name": "Paulo Castellano", diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 73a305a..7a7de72 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,17 +65,17 @@ jobs: steps: - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version: 24 + - name: Build the plugin ZIP - run: git archive --format=zip --prefix=trypost/ -o trypost-plugin.zip HEAD .codex-plugin .mcp.json skills assets + run: node scripts/build.ts - - name: Check the ZIP layout - run: | - unzip -l trypost-plugin.zip - for file in trypost/.codex-plugin/plugin.json trypost/.mcp.json trypost/skills/trypost/SKILL.md trypost/assets/icon-512.png; do - unzip -l trypost-plugin.zip "$file" > /dev/null || { echo "::error::$file is missing from the ZIP"; exit 1; } - done + - name: List the ZIP + run: unzip -l build/trypost-chatgpt.zip - uses: actions/upload-artifact@v7 with: - name: trypost-plugin - path: trypost-plugin.zip + name: trypost-chatgpt + path: build/trypost-chatgpt.zip diff --git a/.gitignore b/.gitignore index f4aeeab..0fc617f 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ dist/ coverage/ *.tmp *.temp +build/ diff --git a/.grok-plugin/plugin.json b/.grok-plugin/plugin.json index f456d62..07ecd91 100644 --- a/.grok-plugin/plugin.json +++ b/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "trypost", - "version": "2.0.0", + "version": "2.0.1", "description": "Plan, schedule and publish social media posts, manage your queue and approvals, and read post analytics in TryPost.", "author": { "name": "Paulo Castellano", diff --git a/AGENTS.md b/AGENTS.md index abd73c0..5a3f040 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,7 @@ This repository packages TryPost as a plugin for Claude Code, Cursor, Grok, Chat | Manifest and skill tests | `node --test` | | TypeScript type check | `tsc -p .` | | Claude plugin validation | `claude plugin validate --strict` on the plugin, its marketplace and its skills, with a pinned Claude Code version | -| OpenAI plugin package | Builds the upload ZIP, checks its layout and attaches it to the run | +| OpenAI plugin package | Builds the upload ZIP with `node scripts/build.ts` and attaches it to the run | - Pin every tool and action: exact versions for npm packages, a major tag for GitHub actions. Pick versions released at least two weeks earlier. - `.github/workflows/sync-skill.yml` copies the root `SKILL.md` into `skills/trypost/SKILL.md` on each release. Still keep both files identical in every commit; a test compares them. @@ -63,7 +63,7 @@ This repository packages TryPost as a plugin for Claude Code, Cursor, Grok, Chat - Review needs exactly five positive and three negative test cases. Positive cases name tools the skill documents. - Never mention prices, plans, trials, discounts or upgrades in the listing or the skill. - Never put reviewer credentials, the demo video, client secrets or the domain verification token in the package; they go in the OpenAI dashboard. The verification token is served by the TryPost app at `https://app.trypost.it/.well-known/openai-apps-challenge`. -- Build the upload with the command in the README; a test checks that it packs everything the manifest references. +- Build the upload with `node scripts/build.ts`: it writes `build/trypost-chatgpt.zip` (git-ignored) from `HEAD`. The list of packed paths lives only in `scripts/build.ts` (`pluginPaths`); `tests/build.test.ts` checks it covers everything the manifest references. Never commit the ZIP. ## Skill (`SKILL.md`) diff --git a/README.md b/README.md index ddcfd90..ada4800 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,10 @@ This repo ships an OpenAI plugin manifest at `.codex-plugin/plugin.json`, with t Build the ZIP to upload at [platform.openai.com/plugins](https://platform.openai.com/plugins): ```bash -git archive --format=zip --prefix=trypost/ -o trypost-plugin.zip HEAD .codex-plugin .mcp.json skills assets +node scripts/build.ts ``` -Reviewer credentials, the demo video URL and the domain verification token are entered in the dashboard, never in the package. +It writes `build/trypost-chatgpt.zip` (git-ignored) from the last commit, so commit your changes before building. The demo video URL goes in `.codex-plugin/plugin.json` (`extensions.com.openai.review.demo_recording_url`); reviewer credentials and the domain verification token are entered in the dashboard, never in the package. ### Cursor plugin @@ -138,6 +138,7 @@ skills/trypost/ Agent skill (SKILL.md) mcp.json Shared MCP server definition assets/ Logo and icons SKILL.md Same skill, repo root (npx skills add / OpenClaw) +scripts/build.ts Builds the OpenAI plugin ZIP into build/ tests/ Manifest, MCP, skill and packaging tests (TypeScript, node --test) tsconfig.json TypeScript settings for the tests AGENTS.md Rules for contributors and coding agents @@ -151,6 +152,7 @@ Everything is TypeScript, run directly by Node 24+ (no build step, no `package.j ```bash node --test # run the tests +node scripts/build.ts # build the OpenAI plugin ZIP into build/ npm install --no-save --no-package-lock typescript@7.0.2 @types/node@24.19.0 npx tsc -p . # type-check diff --git a/SKILL.md b/SKILL.md index 59f8b70..05cb59b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -12,44 +12,50 @@ TryPost schedules and publishes social posts from one workspace. This plugin reg Docs: https://docs.trypost.it/ai/tools-reference App: https://app.trypost.it +## Connecting + +- Every tool here comes from the `trypost` MCP server and is written `trypost:`. Clients may show the same tool in another form, for example `trypost.list-social-accounts-tool` or `mcp__trypost__list_social_accounts_tool`. If no TryPost tool is listed, search the client's tools for `trypost` before concluding they are missing: some clients load MCP tools on demand. +- The tools are listed only once the user has connected TryPost. If they are missing, or a call asks the user to sign in, the user needs to connect TryPost from the client's plugin or connector settings: that opens TryPost's sign-in (https://app.trypost.it/login), where they pick the workspace to use. No account yet: https://app.trypost.it/register. +- The TryPost web app in a browser is not a substitute for these tools. + ## Authentication - OAuth 2.1 with Dynamic Client Registration and PKCE, scope `mcp:use`. On the first call the client opens a browser: the user signs in, picks the **workspace** this connection may use, and authorizes. Every tool acts on that one workspace; to work in another, connect again and pick it. - API keys (Personal Access Tokens) are for the REST API only. Sent to the MCP endpoint they are refused with `403` `MCP OAuth authorization required.` - Other HTTP errors: `401` (token missing, expired or revoked, or the bound workspace was deleted: reconnect), `403` `Workspace access denied.` (the user no longer belongs to the workspace), `402` `Active subscription required.` (TryPost Cloud account without app access; self-hosted skips this check). -- Times in arguments are ISO 8601; without an offset they are read as UTC. Post times in responses are UTC (`Y-m-d H:i:s`). No tool returns the user's own time zone; each channel's zone is `timezone` in `list-social-accounts-tool`. +- Times in arguments are ISO 8601; without an offset they are read as UTC. Post times in responses are UTC (`Y-m-d H:i:s`). No tool returns the user's own time zone; each channel's zone is `timezone` in `trypost:list-social-accounts-tool`. ## Permissions -`get-workspace-tool` returns `me.is_admin`, `me.requires_approval` and `me.publishes_directly`. +`trypost:get-workspace-tool` returns `me.is_admin`, `me.requires_approval` and `me.publishes_directly`. | Who | Can call | |---|---| | Any member | Posts (create, update, publish, preview, delete, media, notes, metrics), ideas and stages, analytics, labels, signatures, accounts and posting-schedule reads, free slots, Pinterest boards, Discord channels, TikTok creator info, Instagram audio, content types, workspace | -| Members who publish directly (owner, admins, members without approval) | Also `approve-post-tool`, `reject-post-tool`, `set-post-recurrence-tool`, `reorder-queue-tool`, `move-post-to-slot-tool`, every repurpose tool | -| Admins (and the account owner) | Also `update-posting-schedule-tool`, `generate-posting-schedule-tool`, `copy-posting-schedule-tool`, every webhook tool, every API key tool | +| Members who publish directly (owner, admins, members without approval) | Also `trypost:approve-post-tool`, `trypost:reject-post-tool`, `trypost:set-post-recurrence-tool`, `trypost:reorder-queue-tool`, `trypost:move-post-to-slot-tool`, every repurpose tool | +| Admins (and the account owner) | Also `trypost:update-posting-schedule-tool`, `trypost:generate-posting-schedule-tool`, `trypost:copy-posting-schedule-tool`, every webhook tool, every API key tool | A refused call returns a tool error (`This action is unauthorized.`). Ids from another workspace answer as not found. ## The post model - A post has exactly **one channel**: `social_account_id`, `content_type` and `meta` sit at the top level of the post. The channel of an existing post cannot change. -- `create-post-tool` creates one post on one channel. `create-posts-tool` creates **one independent post per destination**; the posts share a `post_group_id` and are then edited, published, approved and deleted one by one. -- `status` (lifecycle): `draft`, `pending_approval`, `scheduled`, `publishing`, `published`, `failed`. The channel side of a post has its own publication status and `error_message` / `platform_url` (see `get-post-tool`). -- Create and update take `status`: `draft` (default on create), `scheduled` (with `scheduled_at`, `queue` or `queue_slot`), or `publishing` (publish now). `publish-post-tool` does the same for an existing draft or scheduled post. +- `trypost:create-post-tool` creates one post on one channel. `trypost:create-posts-tool` creates **one independent post per destination**; the posts share a `post_group_id` and are then edited, published, approved and deleted one by one. +- `status` (lifecycle): `draft`, `pending_approval`, `scheduled`, `publishing`, `published`, `failed`. The channel side of a post has its own publication status and `error_message` / `platform_url` (see `trypost:get-post-tool`). +- Create and update take `status`: `draft` (default on create), `scheduled` (with `scheduled_at`, `queue` or `queue_slot`), or `publishing` (publish now). `trypost:publish-post-tool` does the same for an existing draft or scheduled post. - Posts that are `publishing`, `published` or `failed` cannot be changed or deleted. Deleting a post never removes it from the network. ## Workflow ``` -1. get-workspace-tool # does the caller need approval? -2. list-social-accounts-tool # channel id, status, timezone, max_content_length, has_posting_schedule -3. list-content-types-tool # content_type, media rules, required_meta -4. resolve required meta # get-tiktok-creator-info-tool / list-pinterest-boards-tool / list-discord-channels-tool -5. create-post-tool or create-posts-tool # status draft | scheduled | publishing -6. preview-post-tool # exact text the network gets, with length stats -7. publish-post-tool # for drafts: now, scheduled_at, or queue -8. get-post-metrics-tool # after it is published +1. trypost:get-workspace-tool # does the caller need approval? +2. trypost:list-social-accounts-tool # channel id, status, timezone, max_content_length, has_posting_schedule +3. trypost:list-content-types-tool # content_type, media rules, required_meta +4. resolve required meta # trypost:get-tiktok-creator-info-tool / trypost:list-pinterest-boards-tool / trypost:list-discord-channels-tool +5. trypost:create-post-tool or trypost:create-posts-tool # status draft | scheduled | publishing +6. trypost:preview-post-tool # exact text the network gets, with length stats +7. trypost:publish-post-tool # for drafts: now, scheduled_at, or queue +8. trypost:get-post-metrics-tool # after it is published ``` Accounts with `status` `token_expired` or `disconnected` must be reconnected in the TryPost app; MCP cannot connect, reconnect or disconnect channels. @@ -74,7 +80,7 @@ Accounts with `status` `token_expired` or `disconnected` must be reconnected in | `discord` | Discord | `discord_message` | 2000 | | `google_business` | Google Business Profile | `google_business_post` | 1500 | -The account's real limit is `max_content_length` in `list-social-accounts-tool`. `list-content-types-tool` is the live catalog; summary of the media rules: +The account's real limit is `max_content_length` in `trypost:list-social-accounts-tool`. `trypost:list-content-types-tool` is the live catalog; summary of the media rules: | Content type | Media | |---|---| @@ -99,7 +105,7 @@ When `content_type` is omitted it is chosen like the web composer: Pinterest pic ## Per-network `meta` -Unknown keys are dropped. Enum values are exact strings. `update-post-tool` merges `meta` with the stored settings (`null` clears a key; `thread_replies` replaces the whole list). +Unknown keys are dropped. Enum values are exact strings. `trypost:update-post-tool` merges `meta` with the stored settings (`null` clears a key; `thread_replies` replaces the whole list). | Network | Keys | Required to schedule or publish | |---|---|---| @@ -117,7 +123,7 @@ Unknown keys are dropped. Enum values are exact strings. `update-post-tool` merg Every scheduled or published post also needs text or media. -**Instagram music**: `meta.audio` works only on `instagram_reel` posts from an `instagram-facebook` account. Find a track with `search-instagram-audio-tool` and pass `{id}` (optional `title`, `artist`, `cover_url`, `preview_url`, `audio_volume` / `video_volume` 0–100). On an `instagram` account it is refused. +**Instagram music**: `meta.audio` works only on `instagram_reel` posts from an `instagram-facebook` account. Find a track with `trypost:search-instagram-audio-tool` and pass `{id}` (optional `title`, `artist`, `cover_url`, `preview_url`, `audio_volume` / `video_volume` 0–100). On an `instagram` account it is refused. **Per-media settings** (`media[].meta`): `alt_text` (max 2000), `user_tags` (Instagram feed images: up to 20 `{username, x, y}`, x/y from 0 to 1), `cover_offset_ms` (video cover frame where `supports_video_cover`). @@ -131,20 +137,20 @@ Every scheduled or published post also needs text or media. | Tool | Notes | |---|---| -| `list-posts-tool` | Newest scheduled date first. Filters: `status` (`draft` \| `scheduled` \| `pending_approval` \| `published` \| `failed`), `search`, `channels[]`, `labels[]`, `untagged`, `page` | -| `get-post-tool` | Text, media, labels, status, `schedule_mode`, `scheduled_at`, `published_at`, recurrence, approval fields, `post_group_id`, and the channel's `content_type`, `meta`, status, `error_message`, `platform_url` | -| `create-post-tool` | `social_account_id` (required), `content`, `media[]`, `content_type`, `meta`, `status`, `scheduled_at`, `queue` (`next` \| `top`), `queue_slot`, `label_ids[]` | -| `create-posts-tool` | `status` (required), `destinations[]` (required: `{social_account_id, content_type?, content?, media?, meta?}`), shared `content`, `media[]`, `scheduled_at`, `queue`, `label_ids[]`. No `queue_slot`. Returns `{posts}` | -| `update-post-tool` | `post_id` plus any of `content`, `media[]` (replaces the list), `content_type`, `meta` (merged), `status`, `scheduled_at`, `queue`, `label_ids[]` (replaces). `status: draft` unschedules | -| `publish-post-tool` | `post_id`, optional `scheduled_at` or `queue`; omit both to publish now. Validated like a scheduled post; on failure nothing is published and the error names the field | -| `preview-post-tool` | The exact text the network gets (X links shown defused when that is enabled) with length stats. Nothing is truncated | -| `delete-post-tool` | Permanent, TryPost only | -| `get-post-metrics-tool` | Saved analytics of a published post; `unsupported` for unpublished posts or networks without analytics | -| `approve-post-tool` | `post_id`, optional `scheduled_at` or `publish_now`. Required when the requested time has passed or the author asked to publish now | -| `reject-post-tool` | Back to draft for its author; no reason stored | -| `set-post-recurrence-tool` | Scheduled post repeats every `interval` (1–365) `frequency` (`day` \| `week` \| `month` \| `year`), `times` (1–100) more times; last occurrence on or before 2037-12-31 | -| `clear-post-recurrence-tool` | Stops repeating; the post keeps its time | -| `list-post-notes-tool` / `create-post-note-tool` / `update-post-note-tool` / `delete-post-note-tool` | Internal team comments, never published. `body` max 2000. Only the author edits or deletes a note | +| `trypost:list-posts-tool` | Newest scheduled date first. Filters: `status` (`draft` \| `scheduled` \| `pending_approval` \| `published` \| `failed`), `search`, `channels[]`, `labels[]`, `untagged`, `page` | +| `trypost:get-post-tool` | Text, media, labels, status, `schedule_mode`, `scheduled_at`, `published_at`, recurrence, approval fields, `post_group_id`, and the channel's `content_type`, `meta`, status, `error_message`, `platform_url` | +| `trypost:create-post-tool` | `social_account_id` (required), `content`, `media[]`, `content_type`, `meta`, `status`, `scheduled_at`, `queue` (`next` \| `top`), `queue_slot`, `label_ids[]` | +| `trypost:create-posts-tool` | `status` (required), `destinations[]` (required: `{social_account_id, content_type?, content?, media?, meta?}`), shared `content`, `media[]`, `scheduled_at`, `queue`, `label_ids[]`. No `queue_slot`. Returns `{posts}` | +| `trypost:update-post-tool` | `post_id` plus any of `content`, `media[]` (replaces the list), `content_type`, `meta` (merged), `status`, `scheduled_at`, `queue`, `label_ids[]` (replaces). `status: draft` unschedules | +| `trypost:publish-post-tool` | `post_id`, optional `scheduled_at` or `queue`; omit both to publish now. Validated like a scheduled post; on failure nothing is published and the error names the field | +| `trypost:preview-post-tool` | The exact text the network gets (X links shown defused when that is enabled) with length stats. Nothing is truncated | +| `trypost:delete-post-tool` | Permanent, TryPost only | +| `trypost:get-post-metrics-tool` | Saved analytics of a published post; `unsupported` for unpublished posts or networks without analytics | +| `trypost:approve-post-tool` | `post_id`, optional `scheduled_at` or `publish_now`. Required when the requested time has passed or the author asked to publish now | +| `trypost:reject-post-tool` | Back to draft for its author; no reason stored | +| `trypost:set-post-recurrence-tool` | Scheduled post repeats every `interval` (1–365) `frequency` (`day` \| `week` \| `month` \| `year`), `times` (1–100) more times; last occurrence on or before 2037-12-31 | +| `trypost:clear-post-recurrence-tool` | Stops repeating; the post keeps its time | +| `trypost:list-post-notes-tool` / `trypost:create-post-note-tool` / `trypost:update-post-note-tool` / `trypost:delete-post-note-tool` | Internal team comments, never published. `body` max 2000. Only the author edits or deletes a note | ### Media @@ -152,11 +158,11 @@ Media goes in `media[]` on create/update, each item with exactly one of `url` (p | Tool | Notes | |---|---| -| `attach-media-from-url-tool` | `post_id`, `urls[]` (1–10 `{url, alt?, meta?}`). Appends. Returns `attached_count`, `failed_urls` and `failures[]` (`unreachable`, `type_not_allowed`, `too_large`, `host_not_allowed`) | -| `request-media-upload-tool` | One-shot signed URL for a local file: `upload_token`, `upload_url`, `field_name` (`media`), `expires_at` (15 minutes by default), `max_bytes`, `max_bytes_by_type` | -| `attach-media-from-upload-tool` | `post_id`, `upload_token`, optional `alt`, `meta`. Appends | +| `trypost:attach-media-from-url-tool` | `post_id`, `urls[]` (1–10 `{url, alt?, meta?}`). Appends. Returns `attached_count`, `failed_urls` and `failures[]` (`unreachable`, `type_not_allowed`, `too_large`, `host_not_allowed`) | +| `trypost:request-media-upload-tool` | One-shot signed URL for a local file: `upload_token`, `upload_url`, `field_name` (`media`), `expires_at` (15 minutes by default), `max_bytes`, `max_bytes_by_type` | +| `trypost:attach-media-from-upload-tool` | `post_id`, `upload_token`, optional `alt`, `meta`. Appends | -Local file: `request-media-upload-tool`, then `curl -F media=@./clip.mp4 ""`, then pass the `upload_token` in `media[]` or call `attach-media-from-upload-tool`. An upload is kept 24 hours and is spent by the first post that takes it; reuse the file on another post by its media `id`. +Local file: `trypost:request-media-upload-tool`, then `curl -F media=@./clip.mp4 ""`, then pass the `upload_token` in `media[]` or call `trypost:attach-media-from-upload-tool`. An upload is kept 24 hours and is spent by the first post that takes it; reuse the file on another post by its media `id`. Accepted files: JPEG, PNG, GIF, WebP (HEIC/HEIF when the server can convert), MP4, MOV, PDF. Server ceilings by default: 10 MB and 67 megapixels per image, 1 GB per video, 100 MB per PDF; each content type's own limits apply at schedule/publish time. @@ -164,23 +170,23 @@ Accepted files: JPEG, PNG, GIF, WebP (HEIC/HEIF when the server can convert), MP | Tool | Notes | |---|---| -| `list-social-accounts-tool` | `id`, `platform`, `display_name`, `username`, `status`, `timezone`, `posting_goal`, `has_posting_schedule`, `max_content_length`, `long_posts`, `verified_badge` | -| `get-posting-schedule-tool` | `account_id`. Time zone, weekly goal, seven days of local times (day 0 = Sunday) | -| `update-posting-schedule-tool` | `account_id`, `timezone` (IANA), `posting_goal` (1–28 or null), `posting_schedule` (7 `{day, enabled, times[]}`, max 4 `HH:mm` per day, or null to clear) | -| `generate-posting-schedule-tool` | `account_id`, `mode` (`goal` \| `recommended`), `goal` (1–28). Builds from the network's recommended windows; replaces the schedule | -| `copy-posting-schedule-tool` | `account_id`, `from` (a channel with a schedule) | -| `list-free-slots-tool` | Next free slots as UTC instants: at most 30, within 90 days | -| `move-post-to-slot-tool` | `account_id`, `post_id`, `slot_at`. A scheduled post becomes a queue post in that free slot | -| `reorder-queue-tool` | `account_id`, `post_ids[]` (exactly the first queued posts, new order). Posts swap among the slots they hold | -| `list-pinterest-boards-tool` / `create-pinterest-board-tool` | Board `id` → `meta.board_id`. Create takes `name` (max 50) | -| `list-discord-channels-tool` | Text/announcement channels the bot can post to; `id` → `meta.channel_id` | -| `get-tiktok-creator-info-tool` | `privacy_level_options`, `comment_disabled` / `duet_disabled` / `stitch_disabled` (keep the matching `allow_*` false), `max_video_post_duration_sec` (not checked at schedule time), `can_post` | -| `search-instagram-audio-tool` | `account_id` (`instagram-facebook`), optional `q` (omit for trending), `type` (`music` \| `original_sound`) | -| `get-instagram-audio-tool` | `account_id`, `audio_id`. Fails when the track is no longer available | +| `trypost:list-social-accounts-tool` | `id`, `platform`, `display_name`, `username`, `status`, `timezone`, `posting_goal`, `has_posting_schedule`, `max_content_length`, `long_posts`, `verified_badge` | +| `trypost:get-posting-schedule-tool` | `account_id`. Time zone, weekly goal, seven days of local times (day 0 = Sunday) | +| `trypost:update-posting-schedule-tool` | `account_id`, `timezone` (IANA), `posting_goal` (1–28 or null), `posting_schedule` (7 `{day, enabled, times[]}`, max 4 `HH:mm` per day, or null to clear) | +| `trypost:generate-posting-schedule-tool` | `account_id`, `mode` (`goal` \| `recommended`), `goal` (1–28). Builds from the network's recommended windows; replaces the schedule | +| `trypost:copy-posting-schedule-tool` | `account_id`, `from` (a channel with a schedule) | +| `trypost:list-free-slots-tool` | Next free slots as UTC instants: at most 30, within 90 days | +| `trypost:move-post-to-slot-tool` | `account_id`, `post_id`, `slot_at`. A scheduled post becomes a queue post in that free slot | +| `trypost:reorder-queue-tool` | `account_id`, `post_ids[]` (exactly the first queued posts, new order). Posts swap among the slots they hold | +| `trypost:list-pinterest-boards-tool` / `trypost:create-pinterest-board-tool` | Board `id` → `meta.board_id`. Create takes `name` (max 50) | +| `trypost:list-discord-channels-tool` | Text/announcement channels the bot can post to; `id` → `meta.channel_id` | +| `trypost:get-tiktok-creator-info-tool` | `privacy_level_options`, `comment_disabled` / `duet_disabled` / `stitch_disabled` (keep the matching `allow_*` false), `max_video_post_duration_sec` (not checked at schedule time), `can_post` | +| `trypost:search-instagram-audio-tool` | `account_id` (`instagram-facebook`), optional `q` (omit for trending), `type` (`music` \| `original_sound`) | +| `trypost:get-instagram-audio-tool` | `account_id`, `audio_id`. Fails when the track is no longer available | ### Ideas -`list-ideas-tool` (filters `stages[]`, `unassigned`, `labels[]`, `untagged`), `get-idea-tool`, `create-idea-tool` (`title`, `body`, `idea_stage_id`, `media_ids[]`, `label_ids[]`; needs a title, body or media), `update-idea-tool` (`media_ids` / `label_ids` replace), `duplicate-idea-tool`, `delete-ideas-tool` (up to 500), `move-ideas-tool` (`idea_id`, `idea_stage_id` or none, `idea_ids[]` = the full target column in order). Stages: `list-idea-stages-tool`, `create-idea-stage-tool` (`name` max 60), `update-idea-stage-tool`, `delete-idea-stage-tool` (ideas move to unassigned), `reorder-idea-stages-tool` (every stage id). Ideas cannot be converted into posts by MCP; create a post with the idea's text. +`trypost:list-ideas-tool` (filters `stages[]`, `unassigned`, `labels[]`, `untagged`), `trypost:get-idea-tool`, `trypost:create-idea-tool` (`title`, `body`, `idea_stage_id`, `media_ids[]`, `label_ids[]`; needs a title, body or media), `trypost:update-idea-tool` (`media_ids` / `label_ids` replace), `trypost:duplicate-idea-tool`, `trypost:delete-ideas-tool` (up to 500), `trypost:move-ideas-tool` (`idea_id`, `idea_stage_id` or none, `idea_ids[]` = the full target column in order). Stages: `trypost:list-idea-stages-tool`, `trypost:create-idea-stage-tool` (`name` max 60), `trypost:update-idea-stage-tool`, `trypost:delete-idea-stage-tool` (ideas move to unassigned), `trypost:reorder-idea-stages-tool` (every stage id). Ideas cannot be converted into posts by MCP; create a post with the idea's text. ### Analytics @@ -188,10 +194,10 @@ Ranges: `range` `7d` \| `30d` (default) \| `mtd` \| `custom` with `start` / `end | Tool | Notes | |---|---| -| `get-analytics-report-tool` | Workspace summary, follower and post charts, top posts, per-account performance. Optional `channels[]` | -| `get-analytics-publication-tool` | `publication_id` from the report's top posts, including posts imported from the network | -| `get-channel-insights-tool` | `account_id`; summary, metrics and series vs the previous range, plus 12-month follower `growth`. Filters `labels[]`, `untagged`, `types[]` | -| `list-channel-publications-tool` | `account_id`; best posts ranked by `sort`, `period` `current` \| `previous`, top 50, paginated | +| `trypost:get-analytics-report-tool` | Workspace summary, follower and post charts, top posts, per-account performance. Optional `channels[]` | +| `trypost:get-analytics-publication-tool` | `publication_id` from the report's top posts, including posts imported from the network | +| `trypost:get-channel-insights-tool` | `account_id`; summary, metrics and series vs the previous range, plus 12-month follower `growth`. Filters `labels[]`, `untagged`, `types[]` | +| `trypost:list-channel-publications-tool` | `account_id`; best posts ranked by `sort`, `period` `current` \| `previous`, top 50, paginated | Channel insights and channel publications are not available for LinkedIn, LinkedIn Page, Telegram, Discord and Google Business. @@ -201,54 +207,54 @@ Watches one Instagram or Facebook account for one format (`reel`, `video`, `stor | Tool | Notes | |---|---| -| `list-repurpose-source-formats-tool` | Watchable formats | -| `list-repurposes-tool` / `get-repurpose-tool` | Rows with destinations, status and last polling error | -| `create-repurpose-tool` | `source_social_account_id`, `source_format` (default `reel`), `publish_mode` (`publish` default \| `draft`), `destinations[]` (`{social_account_id, content_type, meta?}`, a type that accepts video). Starts as draft. One repurpose per source and format; the source cannot be a destination; Google Business cannot be a destination | -| `update-repurpose-tool` | Changing source or format resets the watermark | -| `activate-repurpose-tool` | Needs a usable source, one destination and the destinations' required meta. Only videos published from then on are copied | -| `pause-repurpose-tool` / `resume-repurpose-tool` | Pause keeps the watermark; resuming a pause made by TryPost starts from now | -| `disable-repurpose-tool` | Clears the watermark | -| `list-repurpose-items-tool` | Every source video seen and why one was skipped | -| `delete-repurpose-tool` | Stops polling; posts already created stay | +| `trypost:list-repurpose-source-formats-tool` | Watchable formats | +| `trypost:list-repurposes-tool` / `trypost:get-repurpose-tool` | Rows with destinations, status and last polling error | +| `trypost:create-repurpose-tool` | `source_social_account_id`, `source_format` (default `reel`), `publish_mode` (`publish` default \| `draft`), `destinations[]` (`{social_account_id, content_type, meta?}`, a type that accepts video). Starts as draft. One repurpose per source and format; the source cannot be a destination; Google Business cannot be a destination | +| `trypost:update-repurpose-tool` | Changing source or format resets the watermark | +| `trypost:activate-repurpose-tool` | Needs a usable source, one destination and the destinations' required meta. Only videos published from then on are copied | +| `trypost:pause-repurpose-tool` / `trypost:resume-repurpose-tool` | Pause keeps the watermark; resuming a pause made by TryPost starts from now | +| `trypost:disable-repurpose-tool` | Clears the watermark | +| `trypost:list-repurpose-items-tool` | Every source video seen and why one was skipped | +| `trypost:delete-repurpose-tool` | Stops polling; posts already created stay | ### Labels, signatures, workspace, platform | Tool | Notes | |---|---| -| `list-labels-tool`, `create-label-tool`, `update-label-tool`, `delete-label-tool` | `name`, `color` (`#RRGGBB`). Deleting detaches the label from posts | -| `list-signatures-tool`, `create-signature-tool`, `update-signature-tool`, `delete-signature-tool` | `name`, `content`. A signature is never added automatically: append its content to the post text | -| `get-workspace-tool` | Current workspace and the caller's `me` flags | -| `list-content-types-tool` | Per platform: `allowed_media_types`, `default_content_type`, `max_content_length`, `max_hashtags`, `alt_text_max_length`, `required_meta`. Per content type: media counts, accepted types, GIF/MOV, byte caps, duration, aspect ratios, `captionless`, `document_must_be_alone`, `supports_thread_replies` | +| `trypost:list-labels-tool`, `trypost:create-label-tool`, `trypost:update-label-tool`, `trypost:delete-label-tool` | `name`, `color` (`#RRGGBB`). Deleting detaches the label from posts | +| `trypost:list-signatures-tool`, `trypost:create-signature-tool`, `trypost:update-signature-tool`, `trypost:delete-signature-tool` | `name`, `content`. A signature is never added automatically: append its content to the post text | +| `trypost:get-workspace-tool` | Current workspace and the caller's `me` flags | +| `trypost:list-content-types-tool` | Per platform: `allowed_media_types`, `default_content_type`, `max_content_length`, `max_hashtags`, `alt_text_max_length`, `required_meta`. Per content type: media counts, accepted types, GIF/MOV, byte caps, duration, aspect ratios, `captionless`, `document_must_be_alone`, `supports_thread_replies` | ### Webhooks (admins) Events: `post.created`, `post.scheduled`, `post.unscheduled`, `post.published`, `post.failed`, `post.deleted`. Each delivery is a JSON POST signed in `X-Webhook-Signature` (HMAC-SHA256 hex of the body). After 5 failed deliveries in a row the webhook is `paused`. -`list-webhooks-tool` (no secrets), `get-webhook-tool` (includes `signing_secret`), `create-webhook-tool` (`endpoint` public URL, `events[]`; created enabled; no test sent), `update-webhook-tool` (`endpoint`, `events`, `status` `enabled` \| `disabled`; re-enabling resets failures), `delete-webhook-tool` (with its logs), `send-webhook-test-tool` (signed `webhook.test` ping, no log), `rotate-webhook-secret-tool` (old secret stops at once), `list-webhook-logs-tool`, `replay-webhook-log-tool` (`webhook_id`, `log_id`; new delivery even when paused or disabled). +`trypost:list-webhooks-tool` (no secrets), `trypost:get-webhook-tool` (includes `signing_secret`), `trypost:create-webhook-tool` (`endpoint` public URL, `events[]`; created enabled; no test sent), `trypost:update-webhook-tool` (`endpoint`, `events`, `status` `enabled` \| `disabled`; re-enabling resets failures), `trypost:delete-webhook-tool` (with its logs), `trypost:send-webhook-test-tool` (signed `webhook.test` ping, no log), `trypost:rotate-webhook-secret-tool` (old secret stops at once), `trypost:list-webhook-logs-tool`, `trypost:replay-webhook-log-tool` (`webhook_id`, `log_id`; new delivery even when paused or disabled). ### API keys (admins) -Manage Personal Access Tokens for the REST API (never usable on MCP). `list-api-keys-tool` (metadata only; OAuth connections excluded), `create-api-key-tool` (`name`, optional `expires_at`; returns `plain_token` once), `delete-api-key-tool` (`api_key_id`; integrations using it stop working). +Manage Personal Access Tokens for the REST API (never usable on MCP). `trypost:list-api-keys-tool` (metadata only; OAuth connections excluded), `trypost:create-api-key-tool` (`name`, optional `expires_at`; returns `plain_token` once), `trypost:delete-api-key-tool` (`api_key_id`; integrations using it stop working). ## Queue and slots - The queue needs posting times on the channel (`has_posting_schedule`). `queue: next` takes the first free slot; `queue: top` takes the first slot and shifts the queued posts behind it to the next gap. Do not combine `queue` with `scheduled_at`. -- `queue_slot` (create-post-tool) or `move-post-to-slot-tool` places a post in one exact free slot from `list-free-slots-tool`. A slot held by any scheduled post or by a queue request pending approval is refused. +- `queue_slot` (trypost:create-post-tool) or `trypost:move-post-to-slot-tool` places a post in one exact free slot from `trypost:list-free-slots-tool`. A slot held by any scheduled post or by a queue request pending approval is refused. - A queued post keeps its slot. Deleting, drafting or re-timing it leaves the slot free; nothing is pulled forward. Schedule changes re-place only posts whose slot disappeared. ## Approvals -When `me.requires_approval` is true, scheduling, queueing or publishing stores the post as `pending_approval` instead (also when such a member edits a scheduled post or attaches media to it). A member who publishes directly then calls `approve-post-tool` or `reject-post-tool`. A pending queue request reserves its slot. Pending posts are visible to approvers and to the member who asked. +When `me.requires_approval` is true, scheduling, queueing or publishing stores the post as `pending_approval` instead (also when such a member edits a scheduled post or attaches media to it). A member who publishes directly then calls `trypost:approve-post-tool` or `trypost:reject-post-tool`. A pending queue request reserves its slot. Pending posts are visible to approvers and to the member who asked. ## Gotchas -1. One post = one channel. For several channels use `create-posts-tool`; each result is a separate post sharing `post_group_id`. -2. `create-post-tool` defaults to `draft`. Send `status: scheduled` / `publishing`, or call `publish-post-tool`. +1. One post = one channel. For several channels use `trypost:create-posts-tool`; each result is a separate post sharing `post_group_id`. +2. `trypost:create-post-tool` defaults to `draft`. Send `status: scheduled` / `publishing`, or call `trypost:publish-post-tool`. 3. TikTok `privacy_level`, Pinterest `board_id`, Discord `channel_id`, YouTube title, and Google Business `event` (EVENT/OFFER) are required before scheduling or publishing. 4. The text limit is per account: an X account without `long_posts` is 280. Instagram allows at most 5 hashtags. Stories send no text. -5. `update-post-tool` `media[]` and `label_ids[]` replace the lists; `meta` merges. +5. `trypost:update-post-tool` `media[]` and `label_ids[]` replace the lists; `meta` merges. 6. Instagram music needs an `instagram-facebook` account and an `instagram_reel`. 7. YouTube is Shorts only (`youtube_short`, one video up to 3 minutes). 8. Facebook is a Page, not a personal profile. Google Business Profile posts take at most one image. 9. MCP cannot connect channels, generate content with AI, convert ideas into posts, or manage templates, members or billing; use the web app. -10. `publish-post-tool`, `approve-post-tool`, `rotate-webhook-secret-tool` and the delete tools (except `delete-repurpose-tool`) are marked destructive; read tools are marked read-only. +10. `trypost:publish-post-tool`, `trypost:approve-post-tool`, `trypost:rotate-webhook-secret-tool` and the delete tools (except `trypost:delete-repurpose-tool`) are marked destructive; read tools are marked read-only. diff --git a/scripts/build.ts b/scripts/build.ts new file mode 100644 index 0000000..41519d9 --- /dev/null +++ b/scripts/build.ts @@ -0,0 +1,23 @@ +import { execFileSync } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +export const root = join(import.meta.dirname, '..'); + +export const pluginPaths = ['.codex-plugin', '.mcp.json', 'skills', 'assets']; + +export const outputFile = 'build/trypost-chatgpt.zip'; + +export const buildPluginZip = (): string => { + mkdirSync(join(root, 'build'), { recursive: true }); + + execFileSync('git', ['archive', '--format=zip', '--prefix=trypost/', '-o', outputFile, 'HEAD', ...pluginPaths], { + cwd: root, + }); + + return join(root, outputFile); +}; + +if (import.meta.main) { + console.log(buildPluginZip()); +} diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md new file mode 100644 index 0000000..64bd3cb --- /dev/null +++ b/skills/setup/SKILL.md @@ -0,0 +1,11 @@ +--- +name: trypost-setup +description: Checks that TryPost is connected after the plugin is installed and helps the user sign in, pick a workspace and see their connected channels. Use right after installing TryPost, or when the TryPost tools are not available. +--- + +# TryPost setup + +1. Call `trypost:get-workspace-tool`. When it answers, tell the user which workspace is connected and whether their posts need approval (`me.requires_approval`). +2. Call `trypost:list-social-accounts-tool` and list the connected channels. A channel with `status` `token_expired` or `disconnected` must be reconnected in the TryPost app (Accounts). With no channel, the user connects one in the TryPost app at https://app.trypost.it: channels cannot be connected through the plugin. +3. If the TryPost tools are not listed, or a call asks the user to sign in, they have not connected TryPost yet. Ask them to connect TryPost from the plugin settings: it opens TryPost's sign-in (https://app.trypost.it/login), where they pick the workspace this connection may use. No account yet: https://app.trypost.it/register. Some clients show the tools as `trypost.` or `mcp__trypost__`; search for `trypost` before concluding they are missing. +4. To use another workspace, disconnect TryPost in the plugin settings and connect again, picking that workspace. diff --git a/skills/trypost/SKILL.md b/skills/trypost/SKILL.md index 59f8b70..05cb59b 100644 --- a/skills/trypost/SKILL.md +++ b/skills/trypost/SKILL.md @@ -12,44 +12,50 @@ TryPost schedules and publishes social posts from one workspace. This plugin reg Docs: https://docs.trypost.it/ai/tools-reference App: https://app.trypost.it +## Connecting + +- Every tool here comes from the `trypost` MCP server and is written `trypost:`. Clients may show the same tool in another form, for example `trypost.list-social-accounts-tool` or `mcp__trypost__list_social_accounts_tool`. If no TryPost tool is listed, search the client's tools for `trypost` before concluding they are missing: some clients load MCP tools on demand. +- The tools are listed only once the user has connected TryPost. If they are missing, or a call asks the user to sign in, the user needs to connect TryPost from the client's plugin or connector settings: that opens TryPost's sign-in (https://app.trypost.it/login), where they pick the workspace to use. No account yet: https://app.trypost.it/register. +- The TryPost web app in a browser is not a substitute for these tools. + ## Authentication - OAuth 2.1 with Dynamic Client Registration and PKCE, scope `mcp:use`. On the first call the client opens a browser: the user signs in, picks the **workspace** this connection may use, and authorizes. Every tool acts on that one workspace; to work in another, connect again and pick it. - API keys (Personal Access Tokens) are for the REST API only. Sent to the MCP endpoint they are refused with `403` `MCP OAuth authorization required.` - Other HTTP errors: `401` (token missing, expired or revoked, or the bound workspace was deleted: reconnect), `403` `Workspace access denied.` (the user no longer belongs to the workspace), `402` `Active subscription required.` (TryPost Cloud account without app access; self-hosted skips this check). -- Times in arguments are ISO 8601; without an offset they are read as UTC. Post times in responses are UTC (`Y-m-d H:i:s`). No tool returns the user's own time zone; each channel's zone is `timezone` in `list-social-accounts-tool`. +- Times in arguments are ISO 8601; without an offset they are read as UTC. Post times in responses are UTC (`Y-m-d H:i:s`). No tool returns the user's own time zone; each channel's zone is `timezone` in `trypost:list-social-accounts-tool`. ## Permissions -`get-workspace-tool` returns `me.is_admin`, `me.requires_approval` and `me.publishes_directly`. +`trypost:get-workspace-tool` returns `me.is_admin`, `me.requires_approval` and `me.publishes_directly`. | Who | Can call | |---|---| | Any member | Posts (create, update, publish, preview, delete, media, notes, metrics), ideas and stages, analytics, labels, signatures, accounts and posting-schedule reads, free slots, Pinterest boards, Discord channels, TikTok creator info, Instagram audio, content types, workspace | -| Members who publish directly (owner, admins, members without approval) | Also `approve-post-tool`, `reject-post-tool`, `set-post-recurrence-tool`, `reorder-queue-tool`, `move-post-to-slot-tool`, every repurpose tool | -| Admins (and the account owner) | Also `update-posting-schedule-tool`, `generate-posting-schedule-tool`, `copy-posting-schedule-tool`, every webhook tool, every API key tool | +| Members who publish directly (owner, admins, members without approval) | Also `trypost:approve-post-tool`, `trypost:reject-post-tool`, `trypost:set-post-recurrence-tool`, `trypost:reorder-queue-tool`, `trypost:move-post-to-slot-tool`, every repurpose tool | +| Admins (and the account owner) | Also `trypost:update-posting-schedule-tool`, `trypost:generate-posting-schedule-tool`, `trypost:copy-posting-schedule-tool`, every webhook tool, every API key tool | A refused call returns a tool error (`This action is unauthorized.`). Ids from another workspace answer as not found. ## The post model - A post has exactly **one channel**: `social_account_id`, `content_type` and `meta` sit at the top level of the post. The channel of an existing post cannot change. -- `create-post-tool` creates one post on one channel. `create-posts-tool` creates **one independent post per destination**; the posts share a `post_group_id` and are then edited, published, approved and deleted one by one. -- `status` (lifecycle): `draft`, `pending_approval`, `scheduled`, `publishing`, `published`, `failed`. The channel side of a post has its own publication status and `error_message` / `platform_url` (see `get-post-tool`). -- Create and update take `status`: `draft` (default on create), `scheduled` (with `scheduled_at`, `queue` or `queue_slot`), or `publishing` (publish now). `publish-post-tool` does the same for an existing draft or scheduled post. +- `trypost:create-post-tool` creates one post on one channel. `trypost:create-posts-tool` creates **one independent post per destination**; the posts share a `post_group_id` and are then edited, published, approved and deleted one by one. +- `status` (lifecycle): `draft`, `pending_approval`, `scheduled`, `publishing`, `published`, `failed`. The channel side of a post has its own publication status and `error_message` / `platform_url` (see `trypost:get-post-tool`). +- Create and update take `status`: `draft` (default on create), `scheduled` (with `scheduled_at`, `queue` or `queue_slot`), or `publishing` (publish now). `trypost:publish-post-tool` does the same for an existing draft or scheduled post. - Posts that are `publishing`, `published` or `failed` cannot be changed or deleted. Deleting a post never removes it from the network. ## Workflow ``` -1. get-workspace-tool # does the caller need approval? -2. list-social-accounts-tool # channel id, status, timezone, max_content_length, has_posting_schedule -3. list-content-types-tool # content_type, media rules, required_meta -4. resolve required meta # get-tiktok-creator-info-tool / list-pinterest-boards-tool / list-discord-channels-tool -5. create-post-tool or create-posts-tool # status draft | scheduled | publishing -6. preview-post-tool # exact text the network gets, with length stats -7. publish-post-tool # for drafts: now, scheduled_at, or queue -8. get-post-metrics-tool # after it is published +1. trypost:get-workspace-tool # does the caller need approval? +2. trypost:list-social-accounts-tool # channel id, status, timezone, max_content_length, has_posting_schedule +3. trypost:list-content-types-tool # content_type, media rules, required_meta +4. resolve required meta # trypost:get-tiktok-creator-info-tool / trypost:list-pinterest-boards-tool / trypost:list-discord-channels-tool +5. trypost:create-post-tool or trypost:create-posts-tool # status draft | scheduled | publishing +6. trypost:preview-post-tool # exact text the network gets, with length stats +7. trypost:publish-post-tool # for drafts: now, scheduled_at, or queue +8. trypost:get-post-metrics-tool # after it is published ``` Accounts with `status` `token_expired` or `disconnected` must be reconnected in the TryPost app; MCP cannot connect, reconnect or disconnect channels. @@ -74,7 +80,7 @@ Accounts with `status` `token_expired` or `disconnected` must be reconnected in | `discord` | Discord | `discord_message` | 2000 | | `google_business` | Google Business Profile | `google_business_post` | 1500 | -The account's real limit is `max_content_length` in `list-social-accounts-tool`. `list-content-types-tool` is the live catalog; summary of the media rules: +The account's real limit is `max_content_length` in `trypost:list-social-accounts-tool`. `trypost:list-content-types-tool` is the live catalog; summary of the media rules: | Content type | Media | |---|---| @@ -99,7 +105,7 @@ When `content_type` is omitted it is chosen like the web composer: Pinterest pic ## Per-network `meta` -Unknown keys are dropped. Enum values are exact strings. `update-post-tool` merges `meta` with the stored settings (`null` clears a key; `thread_replies` replaces the whole list). +Unknown keys are dropped. Enum values are exact strings. `trypost:update-post-tool` merges `meta` with the stored settings (`null` clears a key; `thread_replies` replaces the whole list). | Network | Keys | Required to schedule or publish | |---|---|---| @@ -117,7 +123,7 @@ Unknown keys are dropped. Enum values are exact strings. `update-post-tool` merg Every scheduled or published post also needs text or media. -**Instagram music**: `meta.audio` works only on `instagram_reel` posts from an `instagram-facebook` account. Find a track with `search-instagram-audio-tool` and pass `{id}` (optional `title`, `artist`, `cover_url`, `preview_url`, `audio_volume` / `video_volume` 0–100). On an `instagram` account it is refused. +**Instagram music**: `meta.audio` works only on `instagram_reel` posts from an `instagram-facebook` account. Find a track with `trypost:search-instagram-audio-tool` and pass `{id}` (optional `title`, `artist`, `cover_url`, `preview_url`, `audio_volume` / `video_volume` 0–100). On an `instagram` account it is refused. **Per-media settings** (`media[].meta`): `alt_text` (max 2000), `user_tags` (Instagram feed images: up to 20 `{username, x, y}`, x/y from 0 to 1), `cover_offset_ms` (video cover frame where `supports_video_cover`). @@ -131,20 +137,20 @@ Every scheduled or published post also needs text or media. | Tool | Notes | |---|---| -| `list-posts-tool` | Newest scheduled date first. Filters: `status` (`draft` \| `scheduled` \| `pending_approval` \| `published` \| `failed`), `search`, `channels[]`, `labels[]`, `untagged`, `page` | -| `get-post-tool` | Text, media, labels, status, `schedule_mode`, `scheduled_at`, `published_at`, recurrence, approval fields, `post_group_id`, and the channel's `content_type`, `meta`, status, `error_message`, `platform_url` | -| `create-post-tool` | `social_account_id` (required), `content`, `media[]`, `content_type`, `meta`, `status`, `scheduled_at`, `queue` (`next` \| `top`), `queue_slot`, `label_ids[]` | -| `create-posts-tool` | `status` (required), `destinations[]` (required: `{social_account_id, content_type?, content?, media?, meta?}`), shared `content`, `media[]`, `scheduled_at`, `queue`, `label_ids[]`. No `queue_slot`. Returns `{posts}` | -| `update-post-tool` | `post_id` plus any of `content`, `media[]` (replaces the list), `content_type`, `meta` (merged), `status`, `scheduled_at`, `queue`, `label_ids[]` (replaces). `status: draft` unschedules | -| `publish-post-tool` | `post_id`, optional `scheduled_at` or `queue`; omit both to publish now. Validated like a scheduled post; on failure nothing is published and the error names the field | -| `preview-post-tool` | The exact text the network gets (X links shown defused when that is enabled) with length stats. Nothing is truncated | -| `delete-post-tool` | Permanent, TryPost only | -| `get-post-metrics-tool` | Saved analytics of a published post; `unsupported` for unpublished posts or networks without analytics | -| `approve-post-tool` | `post_id`, optional `scheduled_at` or `publish_now`. Required when the requested time has passed or the author asked to publish now | -| `reject-post-tool` | Back to draft for its author; no reason stored | -| `set-post-recurrence-tool` | Scheduled post repeats every `interval` (1–365) `frequency` (`day` \| `week` \| `month` \| `year`), `times` (1–100) more times; last occurrence on or before 2037-12-31 | -| `clear-post-recurrence-tool` | Stops repeating; the post keeps its time | -| `list-post-notes-tool` / `create-post-note-tool` / `update-post-note-tool` / `delete-post-note-tool` | Internal team comments, never published. `body` max 2000. Only the author edits or deletes a note | +| `trypost:list-posts-tool` | Newest scheduled date first. Filters: `status` (`draft` \| `scheduled` \| `pending_approval` \| `published` \| `failed`), `search`, `channels[]`, `labels[]`, `untagged`, `page` | +| `trypost:get-post-tool` | Text, media, labels, status, `schedule_mode`, `scheduled_at`, `published_at`, recurrence, approval fields, `post_group_id`, and the channel's `content_type`, `meta`, status, `error_message`, `platform_url` | +| `trypost:create-post-tool` | `social_account_id` (required), `content`, `media[]`, `content_type`, `meta`, `status`, `scheduled_at`, `queue` (`next` \| `top`), `queue_slot`, `label_ids[]` | +| `trypost:create-posts-tool` | `status` (required), `destinations[]` (required: `{social_account_id, content_type?, content?, media?, meta?}`), shared `content`, `media[]`, `scheduled_at`, `queue`, `label_ids[]`. No `queue_slot`. Returns `{posts}` | +| `trypost:update-post-tool` | `post_id` plus any of `content`, `media[]` (replaces the list), `content_type`, `meta` (merged), `status`, `scheduled_at`, `queue`, `label_ids[]` (replaces). `status: draft` unschedules | +| `trypost:publish-post-tool` | `post_id`, optional `scheduled_at` or `queue`; omit both to publish now. Validated like a scheduled post; on failure nothing is published and the error names the field | +| `trypost:preview-post-tool` | The exact text the network gets (X links shown defused when that is enabled) with length stats. Nothing is truncated | +| `trypost:delete-post-tool` | Permanent, TryPost only | +| `trypost:get-post-metrics-tool` | Saved analytics of a published post; `unsupported` for unpublished posts or networks without analytics | +| `trypost:approve-post-tool` | `post_id`, optional `scheduled_at` or `publish_now`. Required when the requested time has passed or the author asked to publish now | +| `trypost:reject-post-tool` | Back to draft for its author; no reason stored | +| `trypost:set-post-recurrence-tool` | Scheduled post repeats every `interval` (1–365) `frequency` (`day` \| `week` \| `month` \| `year`), `times` (1–100) more times; last occurrence on or before 2037-12-31 | +| `trypost:clear-post-recurrence-tool` | Stops repeating; the post keeps its time | +| `trypost:list-post-notes-tool` / `trypost:create-post-note-tool` / `trypost:update-post-note-tool` / `trypost:delete-post-note-tool` | Internal team comments, never published. `body` max 2000. Only the author edits or deletes a note | ### Media @@ -152,11 +158,11 @@ Media goes in `media[]` on create/update, each item with exactly one of `url` (p | Tool | Notes | |---|---| -| `attach-media-from-url-tool` | `post_id`, `urls[]` (1–10 `{url, alt?, meta?}`). Appends. Returns `attached_count`, `failed_urls` and `failures[]` (`unreachable`, `type_not_allowed`, `too_large`, `host_not_allowed`) | -| `request-media-upload-tool` | One-shot signed URL for a local file: `upload_token`, `upload_url`, `field_name` (`media`), `expires_at` (15 minutes by default), `max_bytes`, `max_bytes_by_type` | -| `attach-media-from-upload-tool` | `post_id`, `upload_token`, optional `alt`, `meta`. Appends | +| `trypost:attach-media-from-url-tool` | `post_id`, `urls[]` (1–10 `{url, alt?, meta?}`). Appends. Returns `attached_count`, `failed_urls` and `failures[]` (`unreachable`, `type_not_allowed`, `too_large`, `host_not_allowed`) | +| `trypost:request-media-upload-tool` | One-shot signed URL for a local file: `upload_token`, `upload_url`, `field_name` (`media`), `expires_at` (15 minutes by default), `max_bytes`, `max_bytes_by_type` | +| `trypost:attach-media-from-upload-tool` | `post_id`, `upload_token`, optional `alt`, `meta`. Appends | -Local file: `request-media-upload-tool`, then `curl -F media=@./clip.mp4 ""`, then pass the `upload_token` in `media[]` or call `attach-media-from-upload-tool`. An upload is kept 24 hours and is spent by the first post that takes it; reuse the file on another post by its media `id`. +Local file: `trypost:request-media-upload-tool`, then `curl -F media=@./clip.mp4 ""`, then pass the `upload_token` in `media[]` or call `trypost:attach-media-from-upload-tool`. An upload is kept 24 hours and is spent by the first post that takes it; reuse the file on another post by its media `id`. Accepted files: JPEG, PNG, GIF, WebP (HEIC/HEIF when the server can convert), MP4, MOV, PDF. Server ceilings by default: 10 MB and 67 megapixels per image, 1 GB per video, 100 MB per PDF; each content type's own limits apply at schedule/publish time. @@ -164,23 +170,23 @@ Accepted files: JPEG, PNG, GIF, WebP (HEIC/HEIF when the server can convert), MP | Tool | Notes | |---|---| -| `list-social-accounts-tool` | `id`, `platform`, `display_name`, `username`, `status`, `timezone`, `posting_goal`, `has_posting_schedule`, `max_content_length`, `long_posts`, `verified_badge` | -| `get-posting-schedule-tool` | `account_id`. Time zone, weekly goal, seven days of local times (day 0 = Sunday) | -| `update-posting-schedule-tool` | `account_id`, `timezone` (IANA), `posting_goal` (1–28 or null), `posting_schedule` (7 `{day, enabled, times[]}`, max 4 `HH:mm` per day, or null to clear) | -| `generate-posting-schedule-tool` | `account_id`, `mode` (`goal` \| `recommended`), `goal` (1–28). Builds from the network's recommended windows; replaces the schedule | -| `copy-posting-schedule-tool` | `account_id`, `from` (a channel with a schedule) | -| `list-free-slots-tool` | Next free slots as UTC instants: at most 30, within 90 days | -| `move-post-to-slot-tool` | `account_id`, `post_id`, `slot_at`. A scheduled post becomes a queue post in that free slot | -| `reorder-queue-tool` | `account_id`, `post_ids[]` (exactly the first queued posts, new order). Posts swap among the slots they hold | -| `list-pinterest-boards-tool` / `create-pinterest-board-tool` | Board `id` → `meta.board_id`. Create takes `name` (max 50) | -| `list-discord-channels-tool` | Text/announcement channels the bot can post to; `id` → `meta.channel_id` | -| `get-tiktok-creator-info-tool` | `privacy_level_options`, `comment_disabled` / `duet_disabled` / `stitch_disabled` (keep the matching `allow_*` false), `max_video_post_duration_sec` (not checked at schedule time), `can_post` | -| `search-instagram-audio-tool` | `account_id` (`instagram-facebook`), optional `q` (omit for trending), `type` (`music` \| `original_sound`) | -| `get-instagram-audio-tool` | `account_id`, `audio_id`. Fails when the track is no longer available | +| `trypost:list-social-accounts-tool` | `id`, `platform`, `display_name`, `username`, `status`, `timezone`, `posting_goal`, `has_posting_schedule`, `max_content_length`, `long_posts`, `verified_badge` | +| `trypost:get-posting-schedule-tool` | `account_id`. Time zone, weekly goal, seven days of local times (day 0 = Sunday) | +| `trypost:update-posting-schedule-tool` | `account_id`, `timezone` (IANA), `posting_goal` (1–28 or null), `posting_schedule` (7 `{day, enabled, times[]}`, max 4 `HH:mm` per day, or null to clear) | +| `trypost:generate-posting-schedule-tool` | `account_id`, `mode` (`goal` \| `recommended`), `goal` (1–28). Builds from the network's recommended windows; replaces the schedule | +| `trypost:copy-posting-schedule-tool` | `account_id`, `from` (a channel with a schedule) | +| `trypost:list-free-slots-tool` | Next free slots as UTC instants: at most 30, within 90 days | +| `trypost:move-post-to-slot-tool` | `account_id`, `post_id`, `slot_at`. A scheduled post becomes a queue post in that free slot | +| `trypost:reorder-queue-tool` | `account_id`, `post_ids[]` (exactly the first queued posts, new order). Posts swap among the slots they hold | +| `trypost:list-pinterest-boards-tool` / `trypost:create-pinterest-board-tool` | Board `id` → `meta.board_id`. Create takes `name` (max 50) | +| `trypost:list-discord-channels-tool` | Text/announcement channels the bot can post to; `id` → `meta.channel_id` | +| `trypost:get-tiktok-creator-info-tool` | `privacy_level_options`, `comment_disabled` / `duet_disabled` / `stitch_disabled` (keep the matching `allow_*` false), `max_video_post_duration_sec` (not checked at schedule time), `can_post` | +| `trypost:search-instagram-audio-tool` | `account_id` (`instagram-facebook`), optional `q` (omit for trending), `type` (`music` \| `original_sound`) | +| `trypost:get-instagram-audio-tool` | `account_id`, `audio_id`. Fails when the track is no longer available | ### Ideas -`list-ideas-tool` (filters `stages[]`, `unassigned`, `labels[]`, `untagged`), `get-idea-tool`, `create-idea-tool` (`title`, `body`, `idea_stage_id`, `media_ids[]`, `label_ids[]`; needs a title, body or media), `update-idea-tool` (`media_ids` / `label_ids` replace), `duplicate-idea-tool`, `delete-ideas-tool` (up to 500), `move-ideas-tool` (`idea_id`, `idea_stage_id` or none, `idea_ids[]` = the full target column in order). Stages: `list-idea-stages-tool`, `create-idea-stage-tool` (`name` max 60), `update-idea-stage-tool`, `delete-idea-stage-tool` (ideas move to unassigned), `reorder-idea-stages-tool` (every stage id). Ideas cannot be converted into posts by MCP; create a post with the idea's text. +`trypost:list-ideas-tool` (filters `stages[]`, `unassigned`, `labels[]`, `untagged`), `trypost:get-idea-tool`, `trypost:create-idea-tool` (`title`, `body`, `idea_stage_id`, `media_ids[]`, `label_ids[]`; needs a title, body or media), `trypost:update-idea-tool` (`media_ids` / `label_ids` replace), `trypost:duplicate-idea-tool`, `trypost:delete-ideas-tool` (up to 500), `trypost:move-ideas-tool` (`idea_id`, `idea_stage_id` or none, `idea_ids[]` = the full target column in order). Stages: `trypost:list-idea-stages-tool`, `trypost:create-idea-stage-tool` (`name` max 60), `trypost:update-idea-stage-tool`, `trypost:delete-idea-stage-tool` (ideas move to unassigned), `trypost:reorder-idea-stages-tool` (every stage id). Ideas cannot be converted into posts by MCP; create a post with the idea's text. ### Analytics @@ -188,10 +194,10 @@ Ranges: `range` `7d` \| `30d` (default) \| `mtd` \| `custom` with `start` / `end | Tool | Notes | |---|---| -| `get-analytics-report-tool` | Workspace summary, follower and post charts, top posts, per-account performance. Optional `channels[]` | -| `get-analytics-publication-tool` | `publication_id` from the report's top posts, including posts imported from the network | -| `get-channel-insights-tool` | `account_id`; summary, metrics and series vs the previous range, plus 12-month follower `growth`. Filters `labels[]`, `untagged`, `types[]` | -| `list-channel-publications-tool` | `account_id`; best posts ranked by `sort`, `period` `current` \| `previous`, top 50, paginated | +| `trypost:get-analytics-report-tool` | Workspace summary, follower and post charts, top posts, per-account performance. Optional `channels[]` | +| `trypost:get-analytics-publication-tool` | `publication_id` from the report's top posts, including posts imported from the network | +| `trypost:get-channel-insights-tool` | `account_id`; summary, metrics and series vs the previous range, plus 12-month follower `growth`. Filters `labels[]`, `untagged`, `types[]` | +| `trypost:list-channel-publications-tool` | `account_id`; best posts ranked by `sort`, `period` `current` \| `previous`, top 50, paginated | Channel insights and channel publications are not available for LinkedIn, LinkedIn Page, Telegram, Discord and Google Business. @@ -201,54 +207,54 @@ Watches one Instagram or Facebook account for one format (`reel`, `video`, `stor | Tool | Notes | |---|---| -| `list-repurpose-source-formats-tool` | Watchable formats | -| `list-repurposes-tool` / `get-repurpose-tool` | Rows with destinations, status and last polling error | -| `create-repurpose-tool` | `source_social_account_id`, `source_format` (default `reel`), `publish_mode` (`publish` default \| `draft`), `destinations[]` (`{social_account_id, content_type, meta?}`, a type that accepts video). Starts as draft. One repurpose per source and format; the source cannot be a destination; Google Business cannot be a destination | -| `update-repurpose-tool` | Changing source or format resets the watermark | -| `activate-repurpose-tool` | Needs a usable source, one destination and the destinations' required meta. Only videos published from then on are copied | -| `pause-repurpose-tool` / `resume-repurpose-tool` | Pause keeps the watermark; resuming a pause made by TryPost starts from now | -| `disable-repurpose-tool` | Clears the watermark | -| `list-repurpose-items-tool` | Every source video seen and why one was skipped | -| `delete-repurpose-tool` | Stops polling; posts already created stay | +| `trypost:list-repurpose-source-formats-tool` | Watchable formats | +| `trypost:list-repurposes-tool` / `trypost:get-repurpose-tool` | Rows with destinations, status and last polling error | +| `trypost:create-repurpose-tool` | `source_social_account_id`, `source_format` (default `reel`), `publish_mode` (`publish` default \| `draft`), `destinations[]` (`{social_account_id, content_type, meta?}`, a type that accepts video). Starts as draft. One repurpose per source and format; the source cannot be a destination; Google Business cannot be a destination | +| `trypost:update-repurpose-tool` | Changing source or format resets the watermark | +| `trypost:activate-repurpose-tool` | Needs a usable source, one destination and the destinations' required meta. Only videos published from then on are copied | +| `trypost:pause-repurpose-tool` / `trypost:resume-repurpose-tool` | Pause keeps the watermark; resuming a pause made by TryPost starts from now | +| `trypost:disable-repurpose-tool` | Clears the watermark | +| `trypost:list-repurpose-items-tool` | Every source video seen and why one was skipped | +| `trypost:delete-repurpose-tool` | Stops polling; posts already created stay | ### Labels, signatures, workspace, platform | Tool | Notes | |---|---| -| `list-labels-tool`, `create-label-tool`, `update-label-tool`, `delete-label-tool` | `name`, `color` (`#RRGGBB`). Deleting detaches the label from posts | -| `list-signatures-tool`, `create-signature-tool`, `update-signature-tool`, `delete-signature-tool` | `name`, `content`. A signature is never added automatically: append its content to the post text | -| `get-workspace-tool` | Current workspace and the caller's `me` flags | -| `list-content-types-tool` | Per platform: `allowed_media_types`, `default_content_type`, `max_content_length`, `max_hashtags`, `alt_text_max_length`, `required_meta`. Per content type: media counts, accepted types, GIF/MOV, byte caps, duration, aspect ratios, `captionless`, `document_must_be_alone`, `supports_thread_replies` | +| `trypost:list-labels-tool`, `trypost:create-label-tool`, `trypost:update-label-tool`, `trypost:delete-label-tool` | `name`, `color` (`#RRGGBB`). Deleting detaches the label from posts | +| `trypost:list-signatures-tool`, `trypost:create-signature-tool`, `trypost:update-signature-tool`, `trypost:delete-signature-tool` | `name`, `content`. A signature is never added automatically: append its content to the post text | +| `trypost:get-workspace-tool` | Current workspace and the caller's `me` flags | +| `trypost:list-content-types-tool` | Per platform: `allowed_media_types`, `default_content_type`, `max_content_length`, `max_hashtags`, `alt_text_max_length`, `required_meta`. Per content type: media counts, accepted types, GIF/MOV, byte caps, duration, aspect ratios, `captionless`, `document_must_be_alone`, `supports_thread_replies` | ### Webhooks (admins) Events: `post.created`, `post.scheduled`, `post.unscheduled`, `post.published`, `post.failed`, `post.deleted`. Each delivery is a JSON POST signed in `X-Webhook-Signature` (HMAC-SHA256 hex of the body). After 5 failed deliveries in a row the webhook is `paused`. -`list-webhooks-tool` (no secrets), `get-webhook-tool` (includes `signing_secret`), `create-webhook-tool` (`endpoint` public URL, `events[]`; created enabled; no test sent), `update-webhook-tool` (`endpoint`, `events`, `status` `enabled` \| `disabled`; re-enabling resets failures), `delete-webhook-tool` (with its logs), `send-webhook-test-tool` (signed `webhook.test` ping, no log), `rotate-webhook-secret-tool` (old secret stops at once), `list-webhook-logs-tool`, `replay-webhook-log-tool` (`webhook_id`, `log_id`; new delivery even when paused or disabled). +`trypost:list-webhooks-tool` (no secrets), `trypost:get-webhook-tool` (includes `signing_secret`), `trypost:create-webhook-tool` (`endpoint` public URL, `events[]`; created enabled; no test sent), `trypost:update-webhook-tool` (`endpoint`, `events`, `status` `enabled` \| `disabled`; re-enabling resets failures), `trypost:delete-webhook-tool` (with its logs), `trypost:send-webhook-test-tool` (signed `webhook.test` ping, no log), `trypost:rotate-webhook-secret-tool` (old secret stops at once), `trypost:list-webhook-logs-tool`, `trypost:replay-webhook-log-tool` (`webhook_id`, `log_id`; new delivery even when paused or disabled). ### API keys (admins) -Manage Personal Access Tokens for the REST API (never usable on MCP). `list-api-keys-tool` (metadata only; OAuth connections excluded), `create-api-key-tool` (`name`, optional `expires_at`; returns `plain_token` once), `delete-api-key-tool` (`api_key_id`; integrations using it stop working). +Manage Personal Access Tokens for the REST API (never usable on MCP). `trypost:list-api-keys-tool` (metadata only; OAuth connections excluded), `trypost:create-api-key-tool` (`name`, optional `expires_at`; returns `plain_token` once), `trypost:delete-api-key-tool` (`api_key_id`; integrations using it stop working). ## Queue and slots - The queue needs posting times on the channel (`has_posting_schedule`). `queue: next` takes the first free slot; `queue: top` takes the first slot and shifts the queued posts behind it to the next gap. Do not combine `queue` with `scheduled_at`. -- `queue_slot` (create-post-tool) or `move-post-to-slot-tool` places a post in one exact free slot from `list-free-slots-tool`. A slot held by any scheduled post or by a queue request pending approval is refused. +- `queue_slot` (trypost:create-post-tool) or `trypost:move-post-to-slot-tool` places a post in one exact free slot from `trypost:list-free-slots-tool`. A slot held by any scheduled post or by a queue request pending approval is refused. - A queued post keeps its slot. Deleting, drafting or re-timing it leaves the slot free; nothing is pulled forward. Schedule changes re-place only posts whose slot disappeared. ## Approvals -When `me.requires_approval` is true, scheduling, queueing or publishing stores the post as `pending_approval` instead (also when such a member edits a scheduled post or attaches media to it). A member who publishes directly then calls `approve-post-tool` or `reject-post-tool`. A pending queue request reserves its slot. Pending posts are visible to approvers and to the member who asked. +When `me.requires_approval` is true, scheduling, queueing or publishing stores the post as `pending_approval` instead (also when such a member edits a scheduled post or attaches media to it). A member who publishes directly then calls `trypost:approve-post-tool` or `trypost:reject-post-tool`. A pending queue request reserves its slot. Pending posts are visible to approvers and to the member who asked. ## Gotchas -1. One post = one channel. For several channels use `create-posts-tool`; each result is a separate post sharing `post_group_id`. -2. `create-post-tool` defaults to `draft`. Send `status: scheduled` / `publishing`, or call `publish-post-tool`. +1. One post = one channel. For several channels use `trypost:create-posts-tool`; each result is a separate post sharing `post_group_id`. +2. `trypost:create-post-tool` defaults to `draft`. Send `status: scheduled` / `publishing`, or call `trypost:publish-post-tool`. 3. TikTok `privacy_level`, Pinterest `board_id`, Discord `channel_id`, YouTube title, and Google Business `event` (EVENT/OFFER) are required before scheduling or publishing. 4. The text limit is per account: an X account without `long_posts` is 280. Instagram allows at most 5 hashtags. Stories send no text. -5. `update-post-tool` `media[]` and `label_ids[]` replace the lists; `meta` merges. +5. `trypost:update-post-tool` `media[]` and `label_ids[]` replace the lists; `meta` merges. 6. Instagram music needs an `instagram-facebook` account and an `instagram_reel`. 7. YouTube is Shorts only (`youtube_short`, one video up to 3 minutes). 8. Facebook is a Page, not a personal profile. Google Business Profile posts take at most one image. 9. MCP cannot connect channels, generate content with AI, convert ideas into posts, or manage templates, members or billing; use the web app. -10. `publish-post-tool`, `approve-post-tool`, `rotate-webhook-secret-tool` and the delete tools (except `delete-repurpose-tool`) are marked destructive; read tools are marked read-only. +10. `trypost:publish-post-tool`, `trypost:approve-post-tool`, `trypost:rotate-webhook-secret-tool` and the delete tools (except `trypost:delete-repurpose-tool`) are marked destructive; read tools are marked read-only. diff --git a/tests/build.test.ts b/tests/build.test.ts new file mode 100644 index 0000000..f9ee21f --- /dev/null +++ b/tests/build.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { describe, test } from 'node:test'; + +import { buildPluginZip, outputFile, pluginPaths, root } from '../scripts/build.ts'; +import { exists, read, readJson } from './helpers.ts'; +import type { PluginManifest } from './types.ts'; + +const zipEntries = (file: string): string[] => + execFileSync('unzip', ['-Z1', file], { encoding: 'utf8' }).split('\n').filter(Boolean); + +describe('OpenAI plugin build', () => { + test('packs only paths that exist', () => { + assert.ok(pluginPaths.every(exists), `a packed path does not exist: ${pluginPaths.join(', ')}`); + }); + + test('packs everything the OpenAI manifest references', () => { + const manifest = readJson('.codex-plugin/plugin.json'); + const needed = ['.codex-plugin', '.mcp.json', 'skills', manifest.interface?.logo, manifest.interface?.composerIcon]; + + for (const entry of needed) { + const file = entry?.replace(/^\.\//, '') ?? ''; + + assert.ok( + pluginPaths.some((path) => file === path || file.startsWith(`${path}/`)), + `${file} is not packed`, + ); + } + }); + + test('writes a ZIP into build/ with the plugin under one trypost/ folder', () => { + const file = buildPluginZip(); + const entries = zipEntries(file); + + assert.equal(file, `${root}/${outputFile}`); + + for (const entry of ['.codex-plugin/plugin.json', '.mcp.json', 'skills/trypost/SKILL.md', 'skills/setup/SKILL.md', 'assets/icon-512.png']) { + assert.ok(entries.includes(`trypost/${entry}`), `trypost/${entry} is missing from the ZIP`); + } + + assert.deepEqual( + entries.filter((entry) => !entry.startsWith('trypost/')), + [], + ); + assert.deepEqual( + entries.filter((entry) => /^trypost\/(tests|scripts|\.github|README\.md|AGENTS\.md)/.test(entry)), + [], + ); + }); + + test('keeps the build output out of git', () => { + assert.doesNotThrow(() => execFileSync('git', ['check-ignore', '--quiet', outputFile], { cwd: root })); + }); + + test('the README builds the ZIP with the script', () => { + assert.ok(read('README.md').includes('node scripts/build.ts')); + assert.ok(!read('README.md').includes('git archive')); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 94ce204..971d960 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -55,3 +55,6 @@ export const contrastWithWhite = (hex: string): number => { }; export const toolNames = (text: string): Set => new Set(text.match(/\b[a-z-]+-tool\b/g)); + +export const unqualifiedToolNames = (text: string): string[] => + text.match(/(? execFileSync('git', args, { cwd: root, encoding: 'utf8' }).split('\n').filter(Boolean); @@ -21,24 +20,6 @@ describe('README', () => { test('never counts networks', () => { assert.doesNotMatch(readme, networkCount); }); - - test('the ZIP command packs everything the OpenAI manifest needs', () => { - const [, paths = ''] = readme.match(/git archive .+ HEAD (.+)/) ?? []; - const packed = paths.split(' '); - const manifest = readJson('.codex-plugin/plugin.json'); - const needed = ['.codex-plugin', '.mcp.json', 'skills', manifest.interface?.logo, manifest.interface?.composerIcon]; - - assert.ok(packed.every(exists), `the README packs a path that does not exist: ${packed.join(', ')}`); - - for (const entry of needed) { - const file = entry?.replace(/^\.\//, '') ?? ''; - - assert.ok( - packed.some((path) => file === path || file.startsWith(`${path}/`)), - `${file} is not packed`, - ); - } - }); }); describe('repository', () => { diff --git a/tests/skill.test.ts b/tests/skill.test.ts index 3a17656..463c33a 100644 --- a/tests/skill.test.ts +++ b/tests/skill.test.ts @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; -import { frontMatter, mcpUrl, networkCount, read } from './helpers.ts'; +import { exists, frontMatter, mcpUrl, networkCount, read, readJson, toolNames, unqualifiedToolNames } from './helpers.ts'; +import type { PluginManifest } from './types.ts'; const networks = [ 'Instagram', @@ -55,3 +56,49 @@ describe('skill', () => { } }); }); + +describe('skill tool names and sign-in', () => { + const skill = read('skills/trypost/SKILL.md'); + + test('names every tool with its trypost server', () => { + assert.deepEqual(unqualifiedToolNames(skill), []); + }); + + test('explains where the tools come from and how the user signs in', () => { + assert.ok(skill.includes('## Connecting')); + assert.ok(skill.includes('mcp__trypost__list_social_accounts_tool')); + assert.ok(skill.includes('https://app.trypost.it/login')); + assert.ok(skill.includes('https://app.trypost.it/register')); + }); +}); + +describe('setup skill', () => { + const setup = read('skills/setup/SKILL.md'); + const { name, description = '' } = frontMatter('skills/setup/SKILL.md'); + const onboardingSkill = readJson('.codex-plugin/plugin.json').extensions?.['com.openai']?.onboardingSkill ?? ''; + + test('is the onboarding skill the OpenAI manifest declares', () => { + assert.equal(onboardingSkill, './skills/setup/SKILL.md'); + assert.ok(exists(onboardingSkill)); + }); + + test('front matter is valid', () => { + assert.equal(name, 'trypost-setup'); + assert.ok(description.length > 0 && description.length <= 1024); + }); + + test('only uses tools the main skill documents, by their qualified names', () => { + const documented = toolNames(read('skills/trypost/SKILL.md')); + + assert.deepEqual(unqualifiedToolNames(setup), []); + + for (const tool of toolNames(setup)) { + assert.ok(documented.has(tool), `${tool} is not in the main skill`); + } + }); + + test('tells the user how to sign in and where to create an account', () => { + assert.ok(setup.includes('https://app.trypost.it/login')); + assert.ok(setup.includes('https://app.trypost.it/register')); + }); +}); diff --git a/tests/types.ts b/tests/types.ts index 7fce7ec..facaa56 100644 --- a/tests/types.ts +++ b/tests/types.ts @@ -47,6 +47,7 @@ export type ReviewTestCase = { }; export type OpenAiSettings = { + onboardingSkill?: string; hooks?: unknown; apps?: unknown; review?: { diff --git a/tsconfig.json b/tsconfig.json index 6cfe5ed..e1804f8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,9 @@ "target": "esnext", "module": "esnext", "moduleResolution": "bundler", - "types": ["node"], + "types": [ + "node" + ], "strict": true, "noEmit": true, "allowImportingTsExtensions": true, @@ -12,5 +14,8 @@ "noUncheckedIndexedAccess": true, "skipLibCheck": true }, - "include": ["tests/**/*.ts"] + "include": [ + "scripts/**/*.ts", + "tests/**/*.ts" + ] }