From a4ab5f1ccacc2d9da2a631d4127d86c73a763b37 Mon Sep 17 00:00:00 2001 From: Mark Stralka Date: Thu, 9 Jul 2026 13:35:35 -0400 Subject: [PATCH] MLH-7 Add full Docs API parity and create-response IDs (v1.3.0) Parse Location and Resource-ID headers on 201 creates, implement all 39 Docs API endpoints in CLI and MCP, add endpoint catalog with coverage tests. --- .gitignore | 5 +- CHANGELOG.md | 17 + CLAUDE.md | 110 ++- README.md | 34 +- bin/helpscout.js | 10 +- docs/docs-api-endpoints.json | 390 +++++++++++ eslint.config.js | 4 + package-lock.json | 52 +- package.json | 4 +- scripts/scrape-docs-api-endpoints.mjs | 147 ++++ src/commands/docs/articles.js | 134 +++- src/commands/docs/assets.js | 65 ++ src/commands/docs/categories.js | 119 ++++ src/commands/docs/collections.js | 53 ++ src/commands/docs/redirects.js | 105 +++ src/commands/docs/sites.js | 120 ++++ src/docs-client.js | 32 +- src/http.js | 69 ++ src/mailbox-client.js | 5 +- src/mcp-server-docs.js | 936 ++++++++++++++++++++++++++ src/mcp-server.js | 227 +------ test/docs-api-coverage.test.js | 76 +++ test/docs-client.test.js | 66 ++ test/http.test.js | 72 ++ test/integration/docs.test.js | 21 + test/mailbox-client.test.js | 39 ++ 26 files changed, 2633 insertions(+), 279 deletions(-) create mode 100644 docs/docs-api-endpoints.json create mode 100644 scripts/scrape-docs-api-endpoints.mjs create mode 100644 src/commands/docs/assets.js create mode 100644 src/commands/docs/categories.js create mode 100644 src/commands/docs/redirects.js create mode 100644 src/commands/docs/sites.js create mode 100644 src/mcp-server-docs.js create mode 100644 test/docs-api-coverage.test.js create mode 100644 test/http.test.js diff --git a/.gitignore b/.gitignore index 254bddd..0abce3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ # Dependencies node_modules/ -# Internal docs (scraped API reference + implementation plan — not for public release) -/docs/ +# Internal docs (scraped API reference — catalog is committed) +/docs/* +!/docs/docs-api-endpoints.json # Build output dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f2940..243e34e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.3.0] — 2026-07-09 + +### Added + +- Full Docs API v1 parity — 39 endpoints across articles, assets, categories, collections, redirects, and sites with matching CLI commands and MCP tools (61 MCP tools total, up from 29) +- Shared create-response parsing for `201` responses with empty bodies — Docs API `Location` header and Mailbox API `Resource-ID` header now yield `{ id, location }` on create +- Optional `reload=true` on Docs article/collection create (CLI `--reload`, MCP `reload` param) to return the full resource without a follow-up GET +- Multipart asset upload support (`docs asset create-article`, `docs asset create-settings`) +- Endpoint catalog at `docs/docs-api-endpoints.json` and `npm run scrape:docs-api` Playwright scraper to refresh it +- Coverage test ensuring catalog, CLI commands, and MCP tools stay in sync + +### Changed + +- Docs article list requires `--collection` or `--category` (matches API scoping) +- MCP Docs tools moved to `src/mcp-server-docs.js` via `registerDocsTools()` +- `create_conversation`, `create_customer`, and all Docs create MCP tools return resource IDs when the API provides them + ## [1.2.2] — 2026-07-09 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 28e0178..d76dcda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,13 +23,29 @@ To add it to a specific project instead, add to your project's `.mcp.json` (Clau } ``` -The MCP server exposes 29 tools covering all operations below. Use the MCP tools directly instead of shelling out to the CLI when available. +The MCP server exposes 61 tools covering Mailbox API and full Docs API operations. Use the MCP tools directly instead of shelling out to the CLI when available. ## MCP Tools -`auth_status`, `auth_login`, `auth_logout`, `list_conversations`, `get_conversation`, `create_conversation`, `update_conversation`, `delete_conversation`, `list_threads`, `reply_to_conversation`, `add_note`, `list_customers`, `get_customer`, `create_customer`, `update_customer`, `list_mailboxes`, `list_users`, `get_current_user`, `get_user_status`, `list_user_statuses`, `set_user_status`, `list_tags`, `list_articles`, `get_article`, `search_articles`, `create_article`, `update_article`, `delete_article`, `list_collections`, `get_collection` +**Auth:** `auth_status`, `auth_login`, `auth_logout` -All list tools (`list_conversations`, `list_threads`, `list_customers`, `list_mailboxes`, `list_users`, `list_user_statuses`, `list_tags`, `list_articles`, `search_articles`, `list_collections`) accept an optional `markdown: true` parameter to return a GitHub Flavored Markdown table instead of JSON — useful when displaying results directly to a user in the conversation. +**Inbox:** `list_conversations`, `get_conversation`, `create_conversation`, `update_conversation`, `delete_conversation`, `list_threads`, `reply_to_conversation`, `add_note`, `list_customers`, `get_customer`, `create_customer`, `update_customer`, `list_mailboxes`, `list_users`, `get_current_user`, `get_user_status`, `list_user_statuses`, `set_user_status`, `list_tags` + +**Docs — Articles:** `list_articles`, `get_article`, `search_articles`, `list_related_articles`, `list_article_revisions`, `get_article_revision`, `create_article`, `upload_article`, `update_article`, `update_article_view_count`, `save_article_draft`, `delete_article_draft`, `delete_article` + +**Docs — Collections:** `list_collections`, `get_collection`, `create_collection`, `update_collection`, `delete_collection` + +**Docs — Categories:** `list_categories`, `get_category`, `create_category`, `update_category`, `update_category_order`, `delete_category` + +**Docs — Redirects:** `list_redirects`, `get_redirect`, `find_redirect`, `create_redirect`, `update_redirect`, `delete_redirect` + +**Docs — Sites:** `list_sites`, `get_site`, `create_site`, `update_site`, `delete_site`, `get_site_restrictions`, `update_site_restrictions` + +**Docs — Assets:** `create_article_asset`, `create_settings_asset` + +All list tools accept an optional `markdown: true` parameter to return a GitHub Flavored Markdown table instead of JSON — useful when displaying results directly to a user in the conversation. + +Create tools return `{ id, location }` when the API responds with `201` and a `Location` or `Resource-ID` header. Pass `reload: true` on Docs article/collection create to receive the full resource in one call. ## Environment Variables @@ -215,43 +231,85 @@ helpscout inbox webhook delete ``` helpscout docs article list [options] - --collection - --status published (default) | notpublished + --collection list articles in a collection (required unless --category) + --category list articles in a category + --status published (default) | notpublished --page --all helpscout docs article get - -helpscout docs article search [options] - --query (required) - --collection - --status - --all +helpscout docs article search --query [--collection ] [--status ] [--all] +helpscout docs article list-related [--all] +helpscout docs article list-revisions [--all] +helpscout docs article get-revision helpscout docs article create [options] - --collection (required) - --name (required) - --text <html> - --status <s> notpublished (default) | published - -helpscout docs article update <id> [options] - --name <title> + --collection <id> (required) + --name <title> (required) --text <html> - --status <s> published | notpublished - + --status <s> notpublished (default) | published + --reload return full article JSON (reload=true query param) + +helpscout docs article upload --collection <id> --name <title> [--text <html>] [--status <s>] +helpscout docs article update <id> [--name] [--text] [--status] +helpscout docs article update-view-count <id> +helpscout docs article save-draft <id> +helpscout docs article delete-draft <id> helpscout docs article delete <id> ``` ### Docs — Collections ``` -helpscout docs collection list [options] - --site <id> - --visibility <v> public | private - --all - +helpscout docs collection list [--site <id>] [--visibility public|private] [--all] helpscout docs collection get <id> +helpscout docs collection create --site <id> --name <name> [--visibility] [--reload] +helpscout docs collection update <id> [--name] [--visibility] +helpscout docs collection delete <id> +``` + +### Docs — Categories + +``` +helpscout docs category list --collection <id> [--all] +helpscout docs category get <id> +helpscout docs category create --collection <id> --name <name> [--slug] [--order] +helpscout docs category update <id> [--name] [--slug] [--order] +helpscout docs category order --collection <id> --json '<[{id,order}]>' +helpscout docs category delete <id> +``` + +### Docs — Redirects + +``` +helpscout docs redirect list --site <id> [--all] +helpscout docs redirect get <id> +helpscout docs redirect find --site <id> --url <path> +helpscout docs redirect create --site <id> --from <path> --to <url> +helpscout docs redirect update <id> [--from] [--to] +helpscout docs redirect delete <id> +``` + +### Docs — Sites + +``` +helpscout docs site list [--all] +helpscout docs site get <id> +helpscout docs site create --title <title> --subdomain <name> +helpscout docs site update <id> [--title] [--subdomain] +helpscout docs site delete <id> +helpscout docs site restrictions get <siteId> +helpscout docs site restrictions update <siteId> --json '<{...}>' +``` + +### Docs — Assets + ``` +helpscout docs asset create-article --file <path> [--width] [--height] +helpscout docs asset create-settings --file <path> +``` + +Endpoint coverage is tracked in `docs/docs-api-endpoints.json`. Run `npm run scrape:docs-api` to refresh metadata from developer.helpscout.com. ## Common Patterns @@ -291,6 +349,6 @@ helpscout docs article list --collection <collection-id> --all ```sh helpscout docs article create --collection <collection-id> --name "My Article" --text "<p>Body here</p>" -# Returns the new article object including its id +# → { "id": "...", "location": "https://docsapi.helpscout.net/v1/articles/..." } helpscout docs article update <new-id> --status published ``` diff --git a/README.md b/README.md index ddd5568..d012546 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # helpscout CLI -A command-line tool for [Help Scout](https://www.helpscout.com), covering both the Mailbox API v2 (conversations, customers, users) and the Docs API v1 (articles, collections). +A command-line tool for [Help Scout](https://www.helpscout.com), covering the Mailbox API v2 (conversations, customers, users) and the full Docs API v1 (articles, collections, categories, redirects, sites, assets). Designed for scripting, automation, and use by AI agents. Ships with an MCP server (`helpscout-mcp`) for direct integration with Claude Code, Claude Desktop, and Cursor. Outputs JSON by default when not in a terminal, pretty tables when it is. @@ -172,30 +172,48 @@ helpscout docs article list --collection <collection-id> --all # Search helpscout docs article search --query "getting started" -# Create a draft, then publish +# Create a draft (returns id from Location header), then publish helpscout docs article create \ --collection <collection-id> \ --name "New Feature Guide" \ --text "<p>Content here.</p>" -helpscout docs article update <id> --status published +# Or create with full body in one call +helpscout docs article create --collection <id> --name "Guide" --reload -# Delete +helpscout docs article update <id> --status published helpscout docs article delete <id> ``` -### Docs Collections +### Docs Collections, Categories, Redirects, Sites ```sh helpscout docs collection list -helpscout docs collection get <id> +helpscout docs collection create --site <site-id> --name "FAQ" + +helpscout docs category list --collection <collection-id> +helpscout docs category create --collection <id> --name "Getting Started" + +helpscout docs redirect list --site <site-id> +helpscout docs redirect create --site <id> --from /old --to https://example.com/new + +helpscout docs site list +helpscout docs site restrictions get <site-id> +``` + +### Docs Assets + +```sh +helpscout docs asset create-article --file ./screenshot.png +helpscout docs asset create-settings --file ./logo.png ``` ## Development ```sh -npm test # run tests -npm run lint # check for lint errors +npm test # run tests +npm run scrape:docs-api # refresh docs/docs-api-endpoints.json from Help Scout docs +npm run lint # check for lint errors npm run lint:fix # auto-fix lint errors npm run format # format with Prettier npm run build # bundle to dist/helpscout and dist/helpscout-mcp diff --git a/bin/helpscout.js b/bin/helpscout.js index 3365f33..0f1b216 100755 --- a/bin/helpscout.js +++ b/bin/helpscout.js @@ -18,6 +18,10 @@ import { makeTagCommand } from '../src/commands/inbox/tags.js'; import { makeWebhookCommand } from '../src/commands/inbox/webhooks.js'; import { makeArticleCommand } from '../src/commands/docs/articles.js'; import { makeCollectionCommand } from '../src/commands/docs/collections.js'; +import { makeCategoryCommand } from '../src/commands/docs/categories.js'; +import { makeRedirectCommand } from '../src/commands/docs/redirects.js'; +import { makeSiteCommand } from '../src/commands/docs/sites.js'; +import { makeAssetCommand } from '../src/commands/docs/assets.js'; const program = new Command(); @@ -47,9 +51,13 @@ program.addCommand(inbox); // ─── helpscout docs ─────────────────────────────────────────────────────────── const docsCmd = new Command('docs'); -docsCmd.description('Docs API v1 — articles and collections'); +docsCmd.description('Docs API v1 — articles, collections, categories, and more'); docsCmd.addCommand(makeArticleCommand()); docsCmd.addCommand(makeCollectionCommand()); +docsCmd.addCommand(makeCategoryCommand()); +docsCmd.addCommand(makeRedirectCommand()); +docsCmd.addCommand(makeSiteCommand()); +docsCmd.addCommand(makeAssetCommand()); program.addCommand(docsCmd); // Global error handler — unhandled rejections shouldn't print a stack trace diff --git a/docs/docs-api-endpoints.json b/docs/docs-api-endpoints.json new file mode 100644 index 0000000..e4f02a4 --- /dev/null +++ b/docs/docs-api-endpoints.json @@ -0,0 +1,390 @@ +{ + "source": "https://developer.helpscout.com/docs-api/", + "scrapedAt": "2026-07-09", + "groups": [ + { + "name": "Articles", + "endpoints": [ + { + "id": "articles.list", + "method": "GET", + "path": "/collections/{collectionId}/articles", + "altPaths": ["/categories/{categoryId}/articles"], + "responsePattern": "json-paged", + "cliCommand": "docs article list", + "mcpTool": "list_articles", + "implemented": true + }, + { + "id": "articles.search", + "method": "GET", + "path": "/search/articles", + "responsePattern": "json-paged", + "cliCommand": "docs article search", + "mcpTool": "search_articles", + "implemented": true + }, + { + "id": "articles.list-related", + "method": "GET", + "path": "/articles/{articleId}/related", + "responsePattern": "json-paged", + "cliCommand": "docs article list-related", + "mcpTool": "list_related_articles", + "implemented": true + }, + { + "id": "articles.list-revisions", + "method": "GET", + "path": "/articles/{articleId}/revisions", + "responsePattern": "json-paged", + "cliCommand": "docs article list-revisions", + "mcpTool": "list_article_revisions", + "implemented": true + }, + { + "id": "articles.get", + "method": "GET", + "path": "/articles/{articleId}", + "responsePattern": "json", + "cliCommand": "docs article get", + "mcpTool": "get_article", + "implemented": true + }, + { + "id": "articles.get-revision", + "method": "GET", + "path": "/articles/{articleId}/revisions/{revisionId}", + "responsePattern": "json", + "cliCommand": "docs article get-revision", + "mcpTool": "get_article_revision", + "implemented": true + }, + { + "id": "articles.create", + "method": "POST", + "path": "/articles", + "responsePattern": "201-location", + "cliCommand": "docs article create", + "mcpTool": "create_article", + "implemented": true + }, + { + "id": "articles.update", + "method": "PUT", + "path": "/articles/{articleId}", + "responsePattern": "204-or-json", + "cliCommand": "docs article update", + "mcpTool": "update_article", + "implemented": true + }, + { + "id": "articles.upload", + "method": "POST", + "path": "/articles/upload", + "responsePattern": "201-location", + "cliCommand": "docs article upload", + "mcpTool": "upload_article", + "implemented": true + }, + { + "id": "articles.update-view-count", + "method": "PUT", + "path": "/articles/{articleId}/view-count", + "responsePattern": "204", + "cliCommand": "docs article update-view-count", + "mcpTool": "update_article_view_count", + "implemented": true + }, + { + "id": "articles.delete", + "method": "DELETE", + "path": "/articles/{articleId}", + "responsePattern": "204", + "cliCommand": "docs article delete", + "mcpTool": "delete_article", + "implemented": true + }, + { + "id": "articles.save-draft", + "method": "PUT", + "path": "/articles/{articleId}/draft", + "responsePattern": "204", + "cliCommand": "docs article save-draft", + "mcpTool": "save_article_draft", + "implemented": true + }, + { + "id": "articles.delete-draft", + "method": "DELETE", + "path": "/articles/{articleId}/draft", + "responsePattern": "204", + "cliCommand": "docs article delete-draft", + "mcpTool": "delete_article_draft", + "implemented": true + } + ] + }, + { + "name": "Assets", + "endpoints": [ + { + "id": "assets.create-article", + "method": "POST", + "path": "/assets/article", + "contentType": "multipart/form-data", + "responsePattern": "201-json", + "cliCommand": "docs asset create-article", + "mcpTool": "create_article_asset", + "implemented": true + }, + { + "id": "assets.create-settings", + "method": "POST", + "path": "/assets/settings", + "contentType": "multipart/form-data", + "responsePattern": "201-json", + "cliCommand": "docs asset create-settings", + "mcpTool": "create_settings_asset", + "implemented": true + } + ] + }, + { + "name": "Categories", + "endpoints": [ + { + "id": "categories.list", + "method": "GET", + "path": "/collections/{collectionId}/categories", + "responsePattern": "json-paged", + "cliCommand": "docs category list", + "mcpTool": "list_categories", + "implemented": true + }, + { + "id": "categories.get", + "method": "GET", + "path": "/categories/{categoryId}", + "responsePattern": "json", + "cliCommand": "docs category get", + "mcpTool": "get_category", + "implemented": true + }, + { + "id": "categories.create", + "method": "POST", + "path": "/categories", + "responsePattern": "201-location", + "cliCommand": "docs category create", + "mcpTool": "create_category", + "implemented": true + }, + { + "id": "categories.update", + "method": "PUT", + "path": "/categories/{categoryId}", + "responsePattern": "204-or-json", + "cliCommand": "docs category update", + "mcpTool": "update_category", + "implemented": true + }, + { + "id": "categories.order", + "method": "PUT", + "path": "/collections/{collectionId}/categories/order", + "responsePattern": "204", + "cliCommand": "docs category order", + "mcpTool": "update_category_order", + "implemented": true + }, + { + "id": "categories.delete", + "method": "DELETE", + "path": "/categories/{categoryId}", + "responsePattern": "204", + "cliCommand": "docs category delete", + "mcpTool": "delete_category", + "implemented": true + } + ] + }, + { + "name": "Collections", + "endpoints": [ + { + "id": "collections.list", + "method": "GET", + "path": "/collections", + "responsePattern": "json-paged", + "cliCommand": "docs collection list", + "mcpTool": "list_collections", + "implemented": true + }, + { + "id": "collections.get", + "method": "GET", + "path": "/collections/{collectionId}", + "responsePattern": "json", + "cliCommand": "docs collection get", + "mcpTool": "get_collection", + "implemented": true + }, + { + "id": "collections.create", + "method": "POST", + "path": "/collections", + "responsePattern": "201-location", + "cliCommand": "docs collection create", + "mcpTool": "create_collection", + "implemented": true + }, + { + "id": "collections.update", + "method": "PUT", + "path": "/collections/{collectionId}", + "responsePattern": "204-or-json", + "cliCommand": "docs collection update", + "mcpTool": "update_collection", + "implemented": true + }, + { + "id": "collections.delete", + "method": "DELETE", + "path": "/collections/{collectionId}", + "responsePattern": "204", + "cliCommand": "docs collection delete", + "mcpTool": "delete_collection", + "implemented": true + } + ] + }, + { + "name": "Redirects", + "endpoints": [ + { + "id": "redirects.list", + "method": "GET", + "path": "/redirects/site/{siteId}", + "responsePattern": "json-paged", + "cliCommand": "docs redirect list", + "mcpTool": "list_redirects", + "implemented": true + }, + { + "id": "redirects.get", + "method": "GET", + "path": "/redirects/{redirectId}", + "responsePattern": "json", + "cliCommand": "docs redirect get", + "mcpTool": "get_redirect", + "implemented": true + }, + { + "id": "redirects.find", + "method": "GET", + "path": "/redirects/find", + "responsePattern": "json", + "cliCommand": "docs redirect find", + "mcpTool": "find_redirect", + "implemented": true + }, + { + "id": "redirects.create", + "method": "POST", + "path": "/redirects", + "responsePattern": "201-location", + "cliCommand": "docs redirect create", + "mcpTool": "create_redirect", + "implemented": true + }, + { + "id": "redirects.update", + "method": "PUT", + "path": "/redirects/{redirectId}", + "responsePattern": "204-or-json", + "cliCommand": "docs redirect update", + "mcpTool": "update_redirect", + "implemented": true + }, + { + "id": "redirects.delete", + "method": "DELETE", + "path": "/redirects/{redirectId}", + "responsePattern": "204", + "cliCommand": "docs redirect delete", + "mcpTool": "delete_redirect", + "implemented": true + } + ] + }, + { + "name": "Sites", + "endpoints": [ + { + "id": "sites.list", + "method": "GET", + "path": "/sites", + "responsePattern": "json-paged", + "cliCommand": "docs site list", + "mcpTool": "list_sites", + "implemented": true + }, + { + "id": "sites.get", + "method": "GET", + "path": "/sites/{siteId}", + "responsePattern": "json", + "cliCommand": "docs site get", + "mcpTool": "get_site", + "implemented": true + }, + { + "id": "sites.create", + "method": "POST", + "path": "/sites", + "responsePattern": "201-location", + "cliCommand": "docs site create", + "mcpTool": "create_site", + "implemented": true + }, + { + "id": "sites.update", + "method": "PUT", + "path": "/sites/{siteId}", + "responsePattern": "204-or-json", + "cliCommand": "docs site update", + "mcpTool": "update_site", + "implemented": true + }, + { + "id": "sites.delete", + "method": "DELETE", + "path": "/sites/{siteId}", + "responsePattern": "204", + "cliCommand": "docs site delete", + "mcpTool": "delete_site", + "implemented": true + }, + { + "id": "sites.get-restrictions", + "method": "GET", + "path": "/sites/{siteId}/restrictions", + "responsePattern": "json", + "cliCommand": "docs site restrictions get", + "mcpTool": "get_site_restrictions", + "implemented": true + }, + { + "id": "sites.update-restrictions", + "method": "PUT", + "path": "/sites/{siteId}/restrictions", + "responsePattern": "json", + "cliCommand": "docs site restrictions update", + "mcpTool": "update_site_restrictions", + "implemented": true + } + ] + } + ] +} diff --git a/eslint.config.js b/eslint.config.js index 62273f5..5d43ed8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,8 @@ export default [ process: 'readonly', Buffer: 'readonly', fetch: 'readonly', + FormData: 'readonly', + Blob: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', console: 'readonly', @@ -33,6 +35,8 @@ export default [ languageOptions: { globals: { mock: 'readonly', + FormData: 'readonly', + Blob: 'readonly', }, }, }, diff --git a/package-lock.json b/package-lock.json index fa78baf..2ded9da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "helpscout-cli", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "helpscout-cli", - "version": "1.2.2", + "version": "1.3.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", @@ -23,6 +23,7 @@ "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "lint-staged": "^17.0.8", + "playwright": "^1.52.0", "prettier": "^3.9.4", "simple-git-hooks": "^2.13.1" }, @@ -1713,6 +1714,21 @@ "node": ">= 0.8" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2471,6 +2487,38 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 34a5bae..cd29fb6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "helpscout-cli", - "version": "1.2.2", + "version": "1.3.0", "description": "Help Scout CLI for Mailbox API v2 and Docs API v1", "author": "Mobile Locker <support@mobilelocker.com>", "license": "MIT", @@ -13,6 +13,7 @@ }, "scripts": { "test": "node --test --test-concurrency=1", + "scrape:docs-api": "node scripts/scrape-docs-api-endpoints.mjs", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", @@ -43,6 +44,7 @@ "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", "lint-staged": "^17.0.8", + "playwright": "^1.52.0", "prettier": "^3.9.4", "simple-git-hooks": "^2.13.1" } diff --git a/scripts/scrape-docs-api-endpoints.mjs b/scripts/scrape-docs-api-endpoints.mjs new file mode 100644 index 0000000..439e044 --- /dev/null +++ b/scripts/scrape-docs-api-endpoints.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * Scrape Help Scout Docs API endpoint pages and refresh docs/docs-api-endpoints.json. + * + * Usage: npm run scrape:docs-api + * + * Requires playwright (devDependency). Updates scrapedAt and validates page count + * against the committed catalog structure. + */ +import { writeFile, readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.join(__dirname, '..'); +const CATALOG_PATH = path.join(ROOT, 'docs', 'docs-api-endpoints.json'); +const BASE = 'https://developer.helpscout.com/docs-api'; + +const SIDEBAR_PATHS = [ + '/articles/list/', + '/articles/search/', + '/articles/list-related/', + '/articles/list-revisions/', + '/articles/get/', + '/articles/get-revision/', + '/articles/create/', + '/articles/update/', + '/articles/upload/', + '/articles/update-view-count/', + '/articles/delete/', + '/articles/save-draft/', + '/articles/delete-draft/', + '/assets/create-article/', + '/assets/create-settings/', + '/categories/list/', + '/categories/get/', + '/categories/create/', + '/categories/update/', + '/categories/order/', + '/categories/delete/', + '/collections/list/', + '/collections/get/', + '/collections/create/', + '/collections/update/', + '/collections/delete/', + '/redirects/list/', + '/redirects/get/', + '/redirects/find/', + '/redirects/create/', + '/redirects/update/', + '/redirects/delete/', + '/sites/list/', + '/sites/get/', + '/sites/create/', + '/sites/update/', + '/sites/delete/', + '/sites/get-restrictions/', + '/sites/update-restrictions/', +]; + +function inferResponsePattern(text) { + const lower = text.toLowerCase(); + if (lower.includes('multipart/form-data')) return '201-json'; + if (lower.includes('204 no content')) return '204'; + if (lower.includes('location header')) return '201-location'; + if (lower.includes('application/json')) return 'json'; + return 'unknown'; +} + +function extractMethod(text) { + const match = text.match(/\b(GET|POST|PUT|DELETE|PATCH)\b/); + return match?.[1] ?? null; +} + +function extractPath(text) { + const match = text.match(/https:\/\/docsapi\.helpscout\.net\/v1([^\s"'<>]+)/); + return match?.[1] ?? null; +} + +async function scrapePages(browser) { + const page = await browser.newPage(); + const scraped = []; + + for (const slug of SIDEBAR_PATHS) { + const url = `${BASE}${slug}`; + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60_000 }); + const bodyText = await page.locator('main, article, .content, body').first().innerText(); + const method = extractMethod(bodyText); + const apiPath = extractPath(bodyText); + scraped.push({ + slug, + url, + method, + path: apiPath, + responsePattern: inferResponsePattern(bodyText), + }); + process.stderr.write(`Scraped ${slug}\n`); + } + + await page.close(); + return scraped; +} + +async function main() { + let playwright; + try { + playwright = await import('playwright'); + } catch { + process.stderr.write( + 'playwright is not installed. Run: npm install --save-dev playwright && npx playwright install chromium\n', + ); + process.exit(1); + } + + const existing = JSON.parse(await readFile(CATALOG_PATH, 'utf8')); + const browser = await playwright.chromium.launch({ headless: true }); + + try { + const scraped = await scrapePages(browser); + const endpointCount = existing.groups.reduce((n, g) => n + g.endpoints.length, 0); + + if (scraped.length !== endpointCount) { + process.stderr.write( + `Warning: scraped ${scraped.length} pages but catalog has ${endpointCount} endpoints\n`, + ); + } + + const updated = { + ...existing, + scrapedAt: new Date().toISOString().slice(0, 10), + scrapeMeta: { + pageCount: scraped.length, + pages: scraped, + }, + }; + + await writeFile(CATALOG_PATH, `${JSON.stringify(updated, null, 2)}\n`); + process.stderr.write(`Updated ${CATALOG_PATH}\n`); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + process.stderr.write(`${err.stack ?? err}\n`); + process.exit(1); +}); diff --git a/src/commands/docs/articles.js b/src/commands/docs/articles.js index 7f7614a..f100f78 100644 --- a/src/commands/docs/articles.js +++ b/src/commands/docs/articles.js @@ -1,8 +1,11 @@ /** * helpscout docs article <action> */ +import { openAsBlob } from 'node:fs'; +import { basename } from 'node:path'; import { Command } from 'commander'; import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; import { output, outputTable } from '../../output.js'; const COLUMNS = [ @@ -19,16 +22,22 @@ export function makeArticleCommand() { cmd .command('list') - .description('List articles') - .option('--collection <id>', 'Filter by collection ID') + .description('List articles in a collection or category') + .option('--collection <id>', 'Collection ID') + .option('--category <id>', 'Category ID') .option('--status <status>', 'Filter by status (published, notpublished)', 'published') .option('--page <n>', 'Page number', '1') .option('--all', 'Fetch all pages') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); - const params = { status: opts.status }; + if (!opts.collection && !opts.category) { + throw new Error('Provide --collection or --category'); + } - const path = opts.collection ? `/collections/${opts.collection}/articles` : '/articles'; + const params = { status: opts.status }; + const path = opts.category + ? `/categories/${opts.category}/articles` + : `/collections/${opts.collection}/articles`; if (opts.all) { const items = await docs.getAll(path, params); @@ -72,6 +81,58 @@ export function makeArticleCommand() { } }); + cmd + .command('list-related <id>') + .description('List articles related to an article') + .option('--status <status>', 'Status filter', 'all') + .option('--all', 'Fetch all pages') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const params = { status: opts.status }; + const path = `/articles/${id}/related`; + + if (opts.all) { + const items = await docs.getAll(path, params); + outputTable(items, COLUMNS, globalOpts); + } else { + const data = await docs.get(path, params); + const items = data?.articles?.items ?? []; + outputTable(items, COLUMNS, globalOpts); + } + }); + + cmd + .command('list-revisions <id>') + .description('List revisions for an article') + .option('--all', 'Fetch all pages') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const path = `/articles/${id}/revisions`; + const revColumns = [ + { key: 'id', header: 'ID' }, + { key: 'articleId', header: 'Article' }, + { key: 'createdAt', header: 'Created' }, + ]; + + if (opts.all) { + const items = await docs.getAll(path); + outputTable(items, revColumns, globalOpts); + } else { + const data = await docs.get(path); + const items = data?.revisions?.items ?? []; + outputTable(items, revColumns, globalOpts); + } + }); + + cmd + .command('get-revision <articleId> <revisionId>') + .description('Get a specific article revision') + .action(async (articleId, revisionId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get(`/articles/${articleId}/revisions/${revisionId}`); + output(data?.revision ?? data, globalOpts); + }); + cmd .command('create') .description('Create an article') @@ -79,6 +140,7 @@ export function makeArticleCommand() { .requiredOption('--name <title>', 'Article title') .option('--text <html>', 'Article body HTML') .option('--status <status>', 'Status (published, notpublished)', 'notpublished') + .option('--reload', 'Return the created article in the response') .action(async (opts, cmd) => { const globalOpts = cmd.optsWithGlobals(); const body = { @@ -88,8 +150,37 @@ export function makeArticleCommand() { }; if (opts.text) body.text = opts.text; - const data = await docs.post('/articles', body); - output(data?.article ?? data ?? { ok: true }, globalOpts); + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.post('/articles', body, params); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('upload') + .description('Create an article by uploading a file (HTML, text, or Markdown)') + .requiredOption('--collection <id>', 'Collection ID') + .requiredOption('--file <path>', 'File to upload') + .option('--name <title>', 'Article title (defaults to file name)') + .option('--category <id>', 'Category ID') + .option('--type <type>', 'html, text, or markdown') + .option('--reload', 'Return the created article in the response') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + + const form = new FormData(); + form.append('key', apiKey); + form.append('collectionId', opts.collection); + if (opts.name) form.append('name', opts.name); + if (opts.category) form.append('categoryId', opts.category); + if (opts.type) form.append('type', opts.type); + const blob = await openAsBlob(opts.file); + form.append('file', blob, basename(opts.file)); + + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.upload('/articles/upload', form, params); + output(normalizeWriteResponse(data), globalOpts); }); cmd @@ -106,7 +197,36 @@ export function makeArticleCommand() { if (opts.status) body.status = opts.status; const data = await docs.put(`/articles/${id}`, body); - output(data?.article ?? { ok: true, id }, globalOpts); + output(normalizeWriteResponse(data, { ok: true, id }), globalOpts); + }); + + cmd + .command('update-view-count <id>') + .description('Increment article view count') + .option('--count <n>', 'Number of views to add', '1') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.put(`/articles/${id}/views`, undefined, { count: opts.count }); + output({ ok: true, id }, globalOpts); + }); + + cmd + .command('save-draft <id>') + .description('Create or update a draft version of an article') + .requiredOption('--text <html>', 'Draft body HTML') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.put(`/articles/${id}/drafts`, { text: opts.text }); + output({ ok: true, id }, globalOpts); + }); + + cmd + .command('delete-draft <id>') + .description('Delete the draft version of an article') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.delete(`/articles/${id}/drafts`); + output({ ok: true, id }, globalOpts); }); cmd diff --git a/src/commands/docs/assets.js b/src/commands/docs/assets.js new file mode 100644 index 0000000..389211e --- /dev/null +++ b/src/commands/docs/assets.js @@ -0,0 +1,65 @@ +/** + * helpscout docs asset <action> + */ +import { openAsBlob } from 'node:fs'; +import { basename } from 'node:path'; +import { Command } from 'commander'; +import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; +import { output } from '../../output.js'; + +async function buildUploadForm(fields, filePath) { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + form.append(key, value); + } + const blob = await openAsBlob(filePath); + form.append('file', blob, basename(filePath)); + return form; +} + +export function makeAssetCommand() { + const cmd = new Command('asset'); + cmd.description('Upload Docs assets'); + + cmd + .command('create-article') + .description('Upload a file for use in an article') + .requiredOption('--article-id <id>', 'Article ID') + .requiredOption('--file <path>', 'File to upload') + .requiredOption('--asset-type <type>', 'image or attachment') + .option('--file-name <name>', 'Override uploaded file name') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + + const fields = { + key: apiKey, + articleId: opts.articleId, + assetType: opts.assetType, + }; + if (opts.fileName) fields.fileName = opts.fileName; + + const form = await buildUploadForm(fields, opts.file); + const data = await docs.upload('/assets/article', form); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('create-settings') + .description('Upload a global Docs settings image (logo, favicon, touchicon)') + .requiredOption('--file <path>', 'Image file to upload') + .requiredOption('--asset-type <type>', 'logo, favicon, or touchicon') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + + const form = await buildUploadForm({ key: apiKey, assetType: opts.assetType }, opts.file); + const data = await docs.upload('/assets/settings', form); + output(normalizeWriteResponse(data), globalOpts); + }); + + return cmd; +} diff --git a/src/commands/docs/categories.js b/src/commands/docs/categories.js new file mode 100644 index 0000000..d5548c9 --- /dev/null +++ b/src/commands/docs/categories.js @@ -0,0 +1,119 @@ +/** + * helpscout docs category <action> + */ +import { Command } from 'commander'; +import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; +import { output, outputTable } from '../../output.js'; + +const COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'slug', header: 'Slug' }, + { key: 'order', header: 'Order' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +export function makeCategoryCommand() { + const cmd = new Command('category'); + cmd.description('Manage Docs categories'); + + cmd + .command('list') + .description('List categories in a collection') + .requiredOption('--collection <id>', 'Collection ID') + .option('--page <n>', 'Page number', '1') + .option('--all', 'Fetch all pages') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const path = `/collections/${opts.collection}/categories`; + + if (opts.all) { + const items = await docs.getAll(path); + outputTable(items, COLUMNS, globalOpts); + } else { + const data = await docs.get(path, { page: opts.page }); + const items = data?.categories?.items ?? []; + outputTable(items, COLUMNS, globalOpts); + } + }); + + cmd + .command('get <id>') + .description('Get a category by ID') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get(`/categories/${id}`); + output(data?.category ?? data, globalOpts); + }); + + cmd + .command('create') + .description('Create a category') + .requiredOption('--collection <id>', 'Collection ID') + .requiredOption('--name <name>', 'Category name') + .option('--slug <slug>', 'URL slug') + .option('--visibility <v>', 'public or private', 'public') + .option('--order <n>', 'Display order', '1') + .option('--default-sort <sort>', 'popularity or name', 'popularity') + .option('--reload', 'Return the created category in the response') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { + collectionId: opts.collection, + name: opts.name, + visibility: opts.visibility, + order: Number(opts.order), + defaultSort: opts.defaultSort, + }; + if (opts.slug) body.slug = opts.slug; + + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.post('/categories', body, params); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('update <id>') + .description('Update a category') + .option('--name <name>', 'Category name') + .option('--slug <slug>', 'URL slug') + .option('--visibility <v>', 'public or private') + .option('--order <n>', 'Display order') + .option('--default-sort <sort>', 'popularity or name') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = {}; + if (opts.name) body.name = opts.name; + if (opts.slug) body.slug = opts.slug; + if (opts.visibility) body.visibility = opts.visibility; + if (opts.order) body.order = Number(opts.order); + if (opts.defaultSort) body.defaultSort = opts.defaultSort; + + const data = await docs.put(`/categories/${id}`, body); + output(normalizeWriteResponse(data, { ok: true, id }), globalOpts); + }); + + cmd + .command('order') + .description('Update category order within a collection') + .requiredOption('--collection <id>', 'Collection ID') + .requiredOption('--json <payload>', 'JSON array of { id, order } objects') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const categories = JSON.parse(opts.json); + await docs.put(`/collections/${opts.collection}/categories`, { categories }); + output({ ok: true }, globalOpts); + }); + + cmd + .command('delete <id>') + .description('Delete a category') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.delete(`/categories/${id}`); + output({ ok: true, id }, globalOpts); + }); + + return cmd; +} diff --git a/src/commands/docs/collections.js b/src/commands/docs/collections.js index 9fcc197..0c532c2 100644 --- a/src/commands/docs/collections.js +++ b/src/commands/docs/collections.js @@ -3,6 +3,7 @@ */ import { Command } from 'commander'; import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; import { output, outputTable } from '../../output.js'; const COLUMNS = [ @@ -49,5 +50,57 @@ export function makeCollectionCommand() { output(data?.collection ?? data, globalOpts); }); + cmd + .command('create') + .description('Create a collection') + .requiredOption('--site <id>', 'Site ID') + .requiredOption('--name <name>', 'Collection name') + .option('--visibility <v>', 'public or private', 'public') + .option('--order <n>', 'Display order', '1') + .option('--description <text>', 'Optional description (max 45 chars)') + .option('--reload', 'Return the created collection in the response') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { + siteId: opts.site, + name: opts.name, + visibility: opts.visibility, + order: Number(opts.order), + }; + if (opts.description) body.description = opts.description; + + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.post('/collections', body, params); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('update <id>') + .description('Update a collection') + .option('--name <name>', 'Collection name') + .option('--visibility <v>', 'public or private') + .option('--order <n>', 'Display order') + .option('--description <text>', 'Description') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = {}; + if (opts.name) body.name = opts.name; + if (opts.visibility) body.visibility = opts.visibility; + if (opts.order) body.order = Number(opts.order); + if (opts.description) body.description = opts.description; + + const data = await docs.put(`/collections/${id}`, body); + output(normalizeWriteResponse(data, { ok: true, id }), globalOpts); + }); + + cmd + .command('delete <id>') + .description('Delete a collection') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.delete(`/collections/${id}`); + output({ ok: true, id }, globalOpts); + }); + return cmd; } diff --git a/src/commands/docs/redirects.js b/src/commands/docs/redirects.js new file mode 100644 index 0000000..43fb88f --- /dev/null +++ b/src/commands/docs/redirects.js @@ -0,0 +1,105 @@ +/** + * helpscout docs redirect <action> + */ +import { Command } from 'commander'; +import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; +import { output, outputTable } from '../../output.js'; + +const COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'siteId', header: 'Site' }, + { key: 'urlMapping', header: 'From' }, + { key: 'redirect', header: 'To' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +export function makeRedirectCommand() { + const cmd = new Command('redirect'); + cmd.description('Manage Docs redirects'); + + cmd + .command('list') + .description('List redirects for a site') + .requiredOption('--site <id>', 'Site ID') + .option('--page <n>', 'Page number', '1') + .option('--all', 'Fetch all pages') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const path = `/redirects/site/${opts.site}`; + + if (opts.all) { + const items = await docs.getAll(path); + outputTable(items, COLUMNS, globalOpts); + } else { + const data = await docs.get(path, { page: opts.page }); + const items = data?.redirects?.items ?? data?.items ?? []; + outputTable(items, COLUMNS, globalOpts); + } + }); + + cmd + .command('get <id>') + .description('Get a redirect by ID') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get(`/redirects/${id}`); + output(data?.redirect ?? data, globalOpts); + }); + + cmd + .command('find') + .description('Find redirect destination for a URL path') + .requiredOption('--site <id>', 'Site ID') + .requiredOption('--url <path>', 'URL path to resolve (e.g. /article/22-title)') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get('/redirects', { siteId: opts.site, url: opts.url }); + output(data, globalOpts); + }); + + cmd + .command('create') + .description('Create a redirect') + .requiredOption('--site <id>', 'Site ID') + .requiredOption('--url-mapping <path>', 'Path to redirect from') + .requiredOption('--redirect <url>', 'Destination URL') + .option('--reload', 'Return the created redirect in the response') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { + siteId: opts.site, + urlMapping: opts.urlMapping, + redirect: opts.redirect, + }; + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.post('/redirects', body, params); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('update <id>') + .description('Update a redirect') + .option('--url-mapping <path>', 'Path to redirect from') + .option('--redirect <url>', 'Destination URL') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = {}; + if (opts.urlMapping) body.urlMapping = opts.urlMapping; + if (opts.redirect) body.redirect = opts.redirect; + + const data = await docs.put(`/redirects/${id}`, body); + output(normalizeWriteResponse(data, { ok: true, id }), globalOpts); + }); + + cmd + .command('delete <id>') + .description('Delete a redirect') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.delete(`/redirects/${id}`); + output({ ok: true, id }, globalOpts); + }); + + return cmd; +} diff --git a/src/commands/docs/sites.js b/src/commands/docs/sites.js new file mode 100644 index 0000000..79255a5 --- /dev/null +++ b/src/commands/docs/sites.js @@ -0,0 +1,120 @@ +/** + * helpscout docs site <action> + */ +import { Command } from 'commander'; +import { docs } from '../../docs-client.js'; +import { normalizeWriteResponse } from '../../http.js'; +import { output, outputTable } from '../../output.js'; + +const COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'title', header: 'Title' }, + { key: 'subDomain', header: 'Subdomain' }, + { key: 'cname', header: 'CNAME' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +export function makeSiteCommand() { + const cmd = new Command('site'); + cmd.description('Manage Docs sites'); + + cmd + .command('list') + .description('List all sites') + .option('--page <n>', 'Page number', '1') + .option('--all', 'Fetch all pages') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + if (opts.all) { + const items = await docs.getAll('/sites'); + outputTable(items, COLUMNS, globalOpts); + } else { + const data = await docs.get('/sites', { page: opts.page }); + const items = data?.sites?.items ?? []; + outputTable(items, COLUMNS, globalOpts); + } + }); + + cmd + .command('get <id>') + .description('Get a site by ID') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get(`/sites/${id}`); + output(data?.site ?? data, globalOpts); + }); + + cmd + .command('create') + .description('Create a site') + .requiredOption('--title <title>', 'Site title') + .requiredOption('--subdomain <name>', 'Subdomain') + .option('--reload', 'Return the created site in the response') + .action(async (opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = { title: opts.title, subDomain: opts.subdomain }; + const params = opts.reload ? { reload: 'true' } : undefined; + const data = await docs.post('/sites', body, params); + output(normalizeWriteResponse(data), globalOpts); + }); + + cmd + .command('update <id>') + .description('Update a site') + .option('--title <title>', 'Site title') + .option('--subdomain <name>', 'Subdomain') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = {}; + if (opts.title) body.title = opts.title; + if (opts.subdomain) body.subDomain = opts.subdomain; + + const data = await docs.put(`/sites/${id}`, body); + output(normalizeWriteResponse(data, { ok: true, id }), globalOpts); + }); + + cmd + .command('delete <id>') + .description('Delete a site') + .action(async (id, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + await docs.delete(`/sites/${id}`); + output({ ok: true, id }, globalOpts); + }); + + const restrictions = new Command('restrictions'); + restrictions.description('Manage site access restrictions'); + + restrictions + .command('get <siteId>') + .description('Get site restriction settings') + .action(async (siteId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const data = await docs.get(`/sites/${siteId}/restricted`); + output(data, globalOpts); + }); + + restrictions + .command('update <siteId>') + .description('Update site restriction settings') + .option('--enabled', 'Enable restrictions') + .option('--disabled', 'Disable restrictions') + .option('--sign-in-url <url>', 'Custom callback sign-in URL') + .action(async (siteId, opts, cmd) => { + const globalOpts = cmd.optsWithGlobals(); + const body = {}; + if (opts.enabled) body.enabled = true; + if (opts.disabled) body.enabled = false; + if (opts.signInUrl) { + body.authentication = 'CALLBACK'; + body.callbackConfiguration = { signInUrl: opts.signInUrl }; + } + + const data = await docs.put(`/sites/${siteId}/restricted`, body); + output(data ?? { ok: true, id: siteId }, globalOpts); + }); + + cmd.addCommand(restrictions); + + return cmd; +} diff --git a/src/docs-client.js b/src/docs-client.js index c520539..a116dea 100644 --- a/src/docs-client.js +++ b/src/docs-client.js @@ -5,7 +5,7 @@ * Rate limiting: X-RateLimit-Reset (Unix timestamp for reset), 429 on limit * Pagination: { page, pages, count, items } envelope */ -import { USER_AGENT, parseJsonBody } from './http.js'; +import { USER_AGENT, parseJsonBody, parseCreatedResponse } from './http.js'; const BASE_URL = 'https://docsapi.helpscout.net/v1'; const MAX_RETRIES = 3; @@ -23,7 +23,7 @@ function buildBasicAuth(apiKey) { return 'Basic ' + Buffer.from(`${apiKey}:X`).toString('base64'); } -async function request(method, path, { body, params } = {}) { +async function request(method, path, { body, params, formData } = {}) { let url = `${BASE_URL}${path}`; if (params) { const qs = new URLSearchParams(params); @@ -37,14 +37,18 @@ async function request(method, path, { body, params } = {}) { method, headers: { Authorization: buildBasicAuth(apiKey), - 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT, }, }; - if (body !== null && body !== undefined) { - options.body = JSON.stringify(body); + if (formData) { + options.body = formData; + } else { + options.headers['Content-Type'] = 'application/json'; + if (body !== null && body !== undefined) { + options.body = JSON.stringify(body); + } } const res = await fetch(url, options); @@ -71,7 +75,8 @@ async function request(method, path, { body, params } = {}) { throw Object.assign(new Error(message), { status: res.status }); } - return parseJsonBody(res); + const parsed = await parseJsonBody(res); + return parseCreatedResponse(res, parsed) ?? parsed; } throw new Error('Exceeded maximum retries due to rate limiting.'); @@ -128,12 +133,19 @@ export const docs = { return results; }, - async post(path, body) { - return request('POST', path, { body }); + async post(path, body, params) { + return request('POST', path, { body, params }); + }, + + async put(path, body, params) { + return request('PUT', path, { body, params }); }, - async put(path, body) { - return request('PUT', path, { body }); + /** + * POST multipart/form-data (assets upload). + */ + async upload(path, formData, params) { + return request('POST', path, { formData, params }); }, async delete(path) { diff --git a/src/http.js b/src/http.js index 7b4a451..7d78ead 100644 --- a/src/http.js +++ b/src/http.js @@ -15,3 +15,72 @@ export async function parseJsonBody(res) { if (!text.trim()) return null; return JSON.parse(text); } + +/** + * Extract resource id from a Help Scout Location header URL. + */ +export function parseLocationId(location) { + if (!location) return null; + const trimmed = location.replace(/\/$/, ''); + const id = trimmed.split('/').pop(); + return id || null; +} + +/** + * Unwrap Docs API single-item envelopes such as { article: { id, ... } }. + */ +export function unwrapSingleItemEnvelope(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) return data; + if ('id' in data) return data; + + for (const value of Object.values(data)) { + if (value && typeof value === 'object' && !Array.isArray(value) && 'id' in value) { + return value; + } + } + + return data; +} + +/** + * Build a create-response object from headers when the body is empty. + * Mailbox API uses Resource-ID; Docs API uses Location. + */ +export function parseCreatedResponse(res, parsedBody) { + if (parsedBody !== null && parsedBody !== undefined) { + return unwrapSingleItemEnvelope(parsedBody); + } + + if (res.status !== 201) return null; + + const resourceId = res.headers.get('Resource-ID'); + const location = res.headers.get('Location'); + + if (resourceId) { + const numericId = Number(resourceId); + return { + id: Number.isNaN(numericId) ? resourceId : numericId, + ...(location ? { location } : {}), + }; + } + + if (location) { + const id = parseLocationId(location); + return id ? { id, location } : null; + } + + return null; +} + +/** + * Normalize Docs/Mailbox write responses for CLI and MCP output. + */ +export function normalizeWriteResponse(data, fallback = { ok: true }) { + if (data === null || data === undefined) return fallback; + const unwrapped = unwrapSingleItemEnvelope(data); + if (unwrapped && typeof unwrapped === 'object' && 'id' in unwrapped) return unwrapped; + if (unwrapped && typeof unwrapped === 'object' && Object.keys(unwrapped).length > 0) { + return unwrapped; + } + return fallback; +} diff --git a/src/mailbox-client.js b/src/mailbox-client.js index c5aa749..827d83d 100644 --- a/src/mailbox-client.js +++ b/src/mailbox-client.js @@ -6,7 +6,7 @@ * Pagination: HAL _embedded with page.totalPages */ import { getAccessToken, forceRefreshToken } from './auth.js'; -import { USER_AGENT, parseJsonBody } from './http.js'; +import { USER_AGENT, parseJsonBody, parseCreatedResponse } from './http.js'; const BASE_URL = 'https://api.helpscout.net/v2'; const MAX_RETRIES = 3; @@ -68,7 +68,8 @@ async function request(method, path, { body, params } = {}) { throw Object.assign(new Error(message), { status: res.status }); } - return parseJsonBody(res); + const parsed = await parseJsonBody(res); + return parseCreatedResponse(res, parsed) ?? parsed; } throw new Error('Exceeded maximum retries due to rate limiting.'); diff --git a/src/mcp-server-docs.js b/src/mcp-server-docs.js new file mode 100644 index 0000000..0a4e8a8 --- /dev/null +++ b/src/mcp-server-docs.js @@ -0,0 +1,936 @@ +/** + * Docs API MCP tools for Help Scout. + */ +import { openAsBlob } from 'node:fs'; +import { basename } from 'node:path'; +import { z } from 'zod'; +import { normalizeWriteResponse } from './http.js'; + +const ARTICLE_COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Title' }, + { key: 'status', header: 'Status' }, + { key: 'collectionId', header: 'Collection' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +const COLLECTION_COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'visibility', header: 'Visibility' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +const CATEGORY_COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'slug', header: 'Slug' }, + { key: 'order', header: 'Order' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +const REDIRECT_COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'siteId', header: 'Site' }, + { key: 'urlMapping', header: 'From' }, + { key: 'redirect', header: 'To' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +const SITE_COLUMNS = [ + { key: 'id', header: 'ID' }, + { key: 'title', header: 'Title' }, + { key: 'subDomain', header: 'Subdomain' }, + { key: 'updatedAt', header: 'Updated' }, +]; + +async function buildUploadForm(fields, filePath) { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + form.append(key, value); + } + const blob = await openAsBlob(filePath); + form.append('file', blob, basename(filePath)); + return form; +} + +export function registerDocsTools(server, { docs, ok, okMarkdown, fail }) { + // ─── Articles ───────────────────────────────────────────────────────────── + + server.registerTool( + 'list_articles', + { + description: 'List Help Scout Docs articles in a collection or category.', + inputSchema: { + collectionId: z.string().optional().describe('Collection ID'), + categoryId: z.string().optional().describe('Category ID'), + status: z.enum(['published', 'notpublished']).optional(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ collectionId, categoryId, status, all, markdown }) => { + try { + if (!collectionId && !categoryId) { + return fail(new Error('Provide collectionId or categoryId')); + } + const params = {}; + if (status) params.status = status; + const path = categoryId + ? `/categories/${categoryId}/articles` + : `/collections/${collectionId}/articles`; + const rows = all + ? await docs.getAll(path, params) + : ((await docs.get(path, params))?.articles?.items ?? []); + if (markdown) return okMarkdown(rows, ARTICLE_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_article', + { + description: 'Get a single Docs article by ID, including full body HTML.', + inputSchema: { id: z.string().describe('Article ID') }, + }, + async ({ id }) => { + try { + const data = await docs.get(`/articles/${id}`); + return ok(data?.article ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'search_articles', + { + description: 'Search Help Scout Docs articles by keyword.', + inputSchema: { + query: z.string(), + collectionId: z.string().optional(), + status: z.enum(['published', 'notpublished']).optional(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ query, collectionId, status, all, markdown }) => { + try { + const params = { query }; + if (collectionId) params.collectionId = collectionId; + if (status) params.status = status; + const rows = all + ? await docs.getAll('/search/articles', params) + : ((await docs.get('/search/articles', params))?.articles?.items ?? []); + if (markdown) return okMarkdown(rows, ARTICLE_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'list_related_articles', + { + description: 'List articles related to a given article.', + inputSchema: { + id: z.string().describe('Article ID'), + status: z.enum(['all', 'published', 'notpublished']).optional(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ id, status, all, markdown }) => { + try { + const params = {}; + if (status) params.status = status; + const path = `/articles/${id}/related`; + const rows = all + ? await docs.getAll(path, params) + : ((await docs.get(path, params))?.articles?.items ?? []); + if (markdown) return okMarkdown(rows, ARTICLE_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'list_article_revisions', + { + description: 'List revisions for a Docs article.', + inputSchema: { + id: z.string().describe('Article ID'), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ id, all, markdown }) => { + try { + const path = `/articles/${id}/revisions`; + const columns = [ + { key: 'id', header: 'ID' }, + { key: 'articleId', header: 'Article' }, + { key: 'createdAt', header: 'Created' }, + ]; + const rows = all + ? await docs.getAll(path) + : ((await docs.get(path))?.revisions?.items ?? []); + if (markdown) return okMarkdown(rows, columns); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_article_revision', + { + description: 'Get a specific Docs article revision.', + inputSchema: { + articleId: z.string(), + revisionId: z.string(), + }, + }, + async ({ articleId, revisionId }) => { + try { + const data = await docs.get(`/articles/${articleId}/revisions/${revisionId}`); + return ok(data?.revision ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_article', + { + description: 'Create a new Help Scout Docs article (draft by default).', + inputSchema: { + collectionId: z.string(), + name: z.string(), + text: z.string().optional(), + status: z.enum(['published', 'notpublished']).optional(), + reload: z.boolean().optional().describe('Return full article in response'), + }, + }, + async ({ collectionId, name, text, status, reload }) => { + try { + const body = { collectionId, name, status: status ?? 'notpublished' }; + if (text) body.text = text; + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.post('/articles', body, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'upload_article', + { + description: 'Create a Docs article by uploading an HTML, text, or Markdown file.', + inputSchema: { + collectionId: z.string(), + filePath: z.string().describe('Absolute path to file on disk'), + name: z.string().optional(), + categoryId: z.string().optional(), + type: z.enum(['html', 'text', 'markdown']).optional(), + reload: z.boolean().optional(), + }, + }, + async ({ collectionId, filePath, name, categoryId, type, reload }) => { + try { + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + const form = new FormData(); + form.append('key', apiKey); + form.append('collectionId', collectionId); + if (name) form.append('name', name); + if (categoryId) form.append('categoryId', categoryId); + if (type) form.append('type', type); + const blob = await openAsBlob(filePath); + form.append('file', blob, basename(filePath)); + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.upload('/articles/upload', form, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_article', + { + description: 'Update an existing Help Scout Docs article.', + inputSchema: { + id: z.string(), + name: z.string().optional(), + text: z.string().optional(), + status: z.enum(['published', 'notpublished']).optional(), + }, + }, + async ({ id, name, text, status }) => { + try { + const body = {}; + if (name) body.name = name; + if (text) body.text = text; + if (status) body.status = status; + const data = await docs.put(`/articles/${id}`, body); + return ok(normalizeWriteResponse(data, { ok: true, id })); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_article_view_count', + { + description: 'Increment the view count for a Docs article.', + inputSchema: { + id: z.string(), + count: z.number().optional().describe('Views to add (default 1)'), + }, + }, + async ({ id, count }) => { + try { + await docs.put(`/articles/${id}/views`, undefined, { count: String(count ?? 1) }); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'save_article_draft', + { + description: 'Create or update a draft version of a Docs article.', + inputSchema: { + id: z.string(), + text: z.string(), + }, + }, + async ({ id, text }) => { + try { + await docs.put(`/articles/${id}/drafts`, { text }); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_article_draft', + { + description: 'Delete the draft version of a Docs article.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/articles/${id}/drafts`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_article', + { + description: 'Delete a Help Scout Docs article.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/articles/${id}`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + // ─── Collections ──────────────────────────────────────────────────────────── + + server.registerTool( + 'list_collections', + { + description: 'List Help Scout Docs collections.', + inputSchema: { + visibility: z.enum(['public', 'private']).optional(), + siteId: z.string().optional(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ visibility, siteId, all, markdown }) => { + try { + const params = {}; + if (visibility) params.visibility = visibility; + if (siteId) params.siteId = siteId; + const rows = all + ? await docs.getAll('/collections', params) + : ((await docs.get('/collections', params))?.collections?.items ?? []); + if (markdown) return okMarkdown(rows, COLLECTION_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_collection', + { + description: 'Get a Help Scout Docs collection by ID.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + const data = await docs.get(`/collections/${id}`); + return ok(data?.collection ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_collection', + { + description: 'Create a Docs collection.', + inputSchema: { + siteId: z.string(), + name: z.string(), + visibility: z.enum(['public', 'private']).optional(), + order: z.number().optional(), + description: z.string().optional(), + reload: z.boolean().optional(), + }, + }, + async ({ siteId, name, visibility, order, description, reload }) => { + try { + const body = { siteId, name, visibility: visibility ?? 'public', order: order ?? 1 }; + if (description) body.description = description; + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.post('/collections', body, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_collection', + { + description: 'Update a Docs collection.', + inputSchema: { + id: z.string(), + name: z.string().optional(), + visibility: z.enum(['public', 'private']).optional(), + order: z.number().optional(), + description: z.string().optional(), + }, + }, + async ({ id, name, visibility, order, description }) => { + try { + const body = {}; + if (name) body.name = name; + if (visibility) body.visibility = visibility; + if (order !== undefined) body.order = order; + if (description) body.description = description; + const data = await docs.put(`/collections/${id}`, body); + return ok(normalizeWriteResponse(data, { ok: true, id })); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_collection', + { + description: 'Delete a Docs collection.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/collections/${id}`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + // ─── Categories ─────────────────────────────────────────────────────────── + + server.registerTool( + 'list_categories', + { + description: 'List categories in a Docs collection.', + inputSchema: { + collectionId: z.string(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ collectionId, all, markdown }) => { + try { + const path = `/collections/${collectionId}/categories`; + const rows = all + ? await docs.getAll(path) + : ((await docs.get(path))?.categories?.items ?? []); + if (markdown) return okMarkdown(rows, CATEGORY_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_category', + { + description: 'Get a Docs category by ID.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + const data = await docs.get(`/categories/${id}`); + return ok(data?.category ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_category', + { + description: 'Create a Docs category.', + inputSchema: { + collectionId: z.string(), + name: z.string(), + slug: z.string().optional(), + visibility: z.enum(['public', 'private']).optional(), + order: z.number().optional(), + defaultSort: z.enum(['popularity', 'name']).optional(), + reload: z.boolean().optional(), + }, + }, + async ({ collectionId, name, slug, visibility, order, defaultSort, reload }) => { + try { + const body = { + collectionId, + name, + visibility: visibility ?? 'public', + order: order ?? 1, + defaultSort: defaultSort ?? 'popularity', + }; + if (slug) body.slug = slug; + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.post('/categories', body, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_category', + { + description: 'Update a Docs category.', + inputSchema: { + id: z.string(), + name: z.string().optional(), + slug: z.string().optional(), + visibility: z.enum(['public', 'private']).optional(), + order: z.number().optional(), + defaultSort: z.enum(['popularity', 'name']).optional(), + }, + }, + async ({ id, name, slug, visibility, order, defaultSort }) => { + try { + const body = {}; + if (name) body.name = name; + if (slug) body.slug = slug; + if (visibility) body.visibility = visibility; + if (order !== undefined) body.order = order; + if (defaultSort) body.defaultSort = defaultSort; + const data = await docs.put(`/categories/${id}`, body); + return ok(normalizeWriteResponse(data, { ok: true, id })); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_category_order', + { + description: 'Update display order for multiple categories in a collection.', + inputSchema: { + collectionId: z.string(), + categories: z + .array(z.object({ id: z.string(), order: z.number() })) + .describe('Array of { id, order }'), + }, + }, + async ({ collectionId, categories }) => { + try { + await docs.put(`/collections/${collectionId}/categories`, { categories }); + return ok({ ok: true, collectionId }); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_category', + { + description: 'Delete a Docs category.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/categories/${id}`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + // ─── Redirects ────────────────────────────────────────────────────────────── + + server.registerTool( + 'list_redirects', + { + description: 'List redirects for a Docs site.', + inputSchema: { + siteId: z.string(), + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ siteId, all, markdown }) => { + try { + const path = `/redirects/site/${siteId}`; + const rows = all + ? await docs.getAll(path) + : ((await docs.get(path))?.redirects?.items ?? []); + if (markdown) return okMarkdown(rows, REDIRECT_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_redirect', + { + description: 'Get a Docs redirect by ID.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + const data = await docs.get(`/redirects/${id}`); + return ok(data?.redirect ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'find_redirect', + { + description: 'Resolve redirect destination for a URL path on a site.', + inputSchema: { + siteId: z.string(), + url: z.string().describe('URL path, e.g. /article/22-title'), + }, + }, + async ({ siteId, url }) => { + try { + const data = await docs.get('/redirects', { siteId, url }); + return ok(data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_redirect', + { + description: 'Create a Docs redirect.', + inputSchema: { + siteId: z.string(), + urlMapping: z.string(), + redirect: z.string(), + reload: z.boolean().optional(), + }, + }, + async ({ siteId, urlMapping, redirect, reload }) => { + try { + const body = { siteId, urlMapping, redirect }; + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.post('/redirects', body, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_redirect', + { + description: 'Update a Docs redirect.', + inputSchema: { + id: z.string(), + urlMapping: z.string().optional(), + redirect: z.string().optional(), + }, + }, + async ({ id, urlMapping, redirect }) => { + try { + const body = {}; + if (urlMapping) body.urlMapping = urlMapping; + if (redirect) body.redirect = redirect; + const data = await docs.put(`/redirects/${id}`, body); + return ok(normalizeWriteResponse(data, { ok: true, id })); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_redirect', + { + description: 'Delete a Docs redirect.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/redirects/${id}`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + // ─── Sites ────────────────────────────────────────────────────────────────── + + server.registerTool( + 'list_sites', + { + description: 'List Help Scout Docs sites.', + inputSchema: { + all: z.boolean().optional(), + markdown: z.boolean().optional(), + }, + }, + async ({ all, markdown }) => { + try { + const rows = all + ? await docs.getAll('/sites') + : ((await docs.get('/sites'))?.sites?.items ?? []); + if (markdown) return okMarkdown(rows, SITE_COLUMNS); + return ok(rows); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_site', + { + description: 'Get a Docs site by ID.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + const data = await docs.get(`/sites/${id}`); + return ok(data?.site ?? data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_site', + { + description: 'Create a Docs site.', + inputSchema: { + title: z.string(), + subDomain: z.string(), + reload: z.boolean().optional(), + }, + }, + async ({ title, subDomain, reload }) => { + try { + const body = { title, subDomain }; + const params = reload ? { reload: 'true' } : undefined; + const data = await docs.post('/sites', body, params); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_site', + { + description: 'Update a Docs site.', + inputSchema: { + id: z.string(), + title: z.string().optional(), + subDomain: z.string().optional(), + }, + }, + async ({ id, title, subDomain }) => { + try { + const body = {}; + if (title) body.title = title; + if (subDomain) body.subDomain = subDomain; + const data = await docs.put(`/sites/${id}`, body); + return ok(normalizeWriteResponse(data, { ok: true, id })); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'delete_site', + { + description: 'Delete a Docs site.', + inputSchema: { id: z.string() }, + }, + async ({ id }) => { + try { + await docs.delete(`/sites/${id}`); + return ok({ ok: true, id }); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'get_site_restrictions', + { + description: 'Get access restriction settings for a Docs site.', + inputSchema: { siteId: z.string() }, + }, + async ({ siteId }) => { + try { + const data = await docs.get(`/sites/${siteId}/restricted`); + return ok(data); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'update_site_restrictions', + { + description: 'Update access restriction settings for a Docs site.', + inputSchema: { + siteId: z.string(), + enabled: z.boolean().optional(), + signInUrl: z.string().optional().describe('Custom callback sign-in URL'), + }, + }, + async ({ siteId, enabled, signInUrl }) => { + try { + const body = {}; + if (enabled !== undefined) body.enabled = enabled; + if (signInUrl) { + body.authentication = 'CALLBACK'; + body.callbackConfiguration = { signInUrl }; + } + const data = await docs.put(`/sites/${siteId}/restricted`, body); + return ok(data ?? { ok: true, siteId }); + } catch (e) { + return fail(e); + } + }, + ); + + // ─── Assets ───────────────────────────────────────────────────────────────── + + server.registerTool( + 'create_article_asset', + { + description: 'Upload a file for use in a Docs article (image or attachment).', + inputSchema: { + articleId: z.string(), + filePath: z.string().describe('Absolute path to file on disk'), + assetType: z.enum(['image', 'attachment']), + fileName: z.string().optional(), + }, + }, + async ({ articleId, filePath, assetType, fileName }) => { + try { + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + const fields = { key: apiKey, articleId, assetType }; + if (fileName) fields.fileName = fileName; + const form = await buildUploadForm(fields, filePath); + const data = await docs.upload('/assets/article', form); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); + + server.registerTool( + 'create_settings_asset', + { + description: 'Upload a global Docs settings image (logo, favicon, or touchicon).', + inputSchema: { + filePath: z.string().describe('Absolute path to image on disk'), + assetType: z.enum(['logo', 'favicon', 'touchicon']), + }, + }, + async ({ filePath, assetType }) => { + try { + const apiKey = process.env.HELPSCOUT_API_KEY; + if (!apiKey) throw new Error('HELPSCOUT_API_KEY environment variable is not set.'); + const form = await buildUploadForm({ key: apiKey, assetType }, filePath); + const data = await docs.upload('/assets/settings', form); + return ok(normalizeWriteResponse(data)); + } catch (e) { + return fail(e); + } + }, + ); +} diff --git a/src/mcp-server.js b/src/mcp-server.js index a0d490a..cfa0a80 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -9,10 +9,12 @@ import { z } from 'zod'; import { login, logout, status } from './auth.js'; import { mailbox, buildCustomStatus } from './mailbox-client.js'; import { docs } from './docs-client.js'; +import { normalizeWriteResponse } from './http.js'; +import { registerDocsTools } from './mcp-server-docs.js'; import { USER_STATUS_COLUMNS } from './columns.js'; import pkg from '../package.json' with { type: 'json' }; -// 29 tools: +// 61 tools: // Auth (3): auth_status, auth_login, auth_logout // Conversations (5): list_conversations, get_conversation, create_conversation, // update_conversation, delete_conversation @@ -22,9 +24,7 @@ import pkg from '../package.json' with { type: 'json' }; // Users (5): list_users, get_current_user (get_user via list), get_user_status, // list_user_statuses, set_user_status // Tags (1): list_tags -// Articles (6): list_articles, get_article, search_articles, create_article, -// update_article, delete_article -// Collections (2): list_collections, get_collection +// Docs (39): see registerDocsTools in mcp-server-docs.js const server = new McpServer({ name: 'helpscout', version: pkg.version }); function ok(data) { @@ -205,7 +205,7 @@ server.registerTool( if (tags) payload.tags = tags; if (assignedTo) payload.assignTo = assignedTo; const data = await mailbox.post('/conversations', payload); - return ok(data ?? { ok: true }); + return ok(normalizeWriteResponse(data)); } catch (e) { return fail(e); } @@ -426,7 +426,7 @@ server.registerTool( if (lastName) body.lastName = lastName; if (phone) body.phones = [{ value: phone, type: 'work' }]; const data = await mailbox.post('/customers', body); - return ok(data ?? { ok: true }); + return ok(normalizeWriteResponse(data)); } catch (e) { return fail(e); } @@ -631,220 +631,7 @@ server.registerTool( }, ); -// ─── Docs Articles ──────────────────────────────────────────────────────────── - -server.registerTool( - 'list_articles', - { - description: 'List Help Scout Docs articles, optionally filtered by collection.', - inputSchema: { - collectionId: z.string().optional().describe('Filter by collection ID'), - status: z - .enum(['published', 'notpublished']) - .optional() - .describe('Filter by status (default: published)'), - all: z.boolean().optional().describe('Fetch all pages'), - markdown: z.boolean().optional().describe('Return a Markdown table instead of JSON'), - }, - }, - async ({ collectionId, status, all, markdown }) => { - try { - const params = {}; - if (status) params.status = status; - const path = collectionId ? `/collections/${collectionId}/articles` : '/articles'; - const rows = all - ? await docs.getAll(path, params) - : ((await docs.get(path, params))?.articles?.items ?? []); - if (markdown) { - return okMarkdown(rows, [ - { key: 'id', header: 'ID' }, - { key: 'name', header: 'Title' }, - { key: 'status', header: 'Status' }, - { key: 'collectionId', header: 'Collection' }, - { key: 'updatedAt', header: 'Updated' }, - ]); - } - return ok(rows); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'get_article', - { - description: 'Get a single Docs article by ID, including full body HTML.', - inputSchema: { - id: z.string().describe('Article ID'), - }, - }, - async ({ id }) => { - try { - const data = await docs.get(`/articles/${id}`); - return ok(data?.article ?? data); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'search_articles', - { - description: 'Search Help Scout Docs articles by keyword.', - inputSchema: { - query: z.string().describe('Search query'), - collectionId: z.string().optional().describe('Limit search to a specific collection'), - status: z.enum(['published', 'notpublished']).optional(), - all: z.boolean().optional().describe('Fetch all pages'), - markdown: z.boolean().optional().describe('Return a Markdown table instead of JSON'), - }, - }, - async ({ query, collectionId, status, all, markdown }) => { - try { - const params = { query }; - if (collectionId) params.collectionId = collectionId; - if (status) params.status = status; - const rows = all - ? await docs.getAll('/search/articles', params) - : ((await docs.get('/search/articles', params))?.articles?.items ?? []); - if (markdown) { - return okMarkdown(rows, [ - { key: 'id', header: 'ID' }, - { key: 'name', header: 'Title' }, - { key: 'status', header: 'Status' }, - { key: 'collectionId', header: 'Collection' }, - { key: 'updatedAt', header: 'Updated' }, - ]); - } - return ok(rows); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'create_article', - { - description: 'Create a new Help Scout Docs article (draft by default).', - inputSchema: { - collectionId: z.string().describe('Collection ID to create the article in'), - name: z.string().describe('Article title'), - text: z.string().optional().describe('Article body HTML'), - status: z - .enum(['published', 'notpublished']) - .optional() - .describe('Status (default: notpublished)'), - }, - }, - async ({ collectionId, name, text, status }) => { - try { - const body = { collectionId, name, status: status ?? 'notpublished' }; - if (text) body.text = text; - const data = await docs.post('/articles', body); - return ok(data?.article ?? data ?? { ok: true }); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'update_article', - { - description: 'Update an existing Help Scout Docs article.', - inputSchema: { - id: z.string().describe('Article ID'), - name: z.string().optional().describe('New title'), - text: z.string().optional().describe('New body HTML'), - status: z.enum(['published', 'notpublished']).optional(), - }, - }, - async ({ id, name, text, status }) => { - try { - const body = {}; - if (name) body.name = name; - if (text) body.text = text; - if (status) body.status = status; - const data = await docs.put(`/articles/${id}`, body); - return ok(data?.article ?? { ok: true, id }); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'delete_article', - { - description: 'Delete a Help Scout Docs article.', - inputSchema: { - id: z.string().describe('Article ID'), - }, - }, - async ({ id }) => { - try { - await docs.delete(`/articles/${id}`); - return ok({ ok: true, id }); - } catch (e) { - return fail(e); - } - }, -); - -// ─── Docs Collections ───────────────────────────────────────────────────────── - -server.registerTool( - 'list_collections', - { - description: 'List Help Scout Docs collections. Use this to get collection IDs.', - inputSchema: { - visibility: z.enum(['public', 'private']).optional(), - all: z.boolean().optional().describe('Fetch all pages'), - markdown: z.boolean().optional().describe('Return a Markdown table instead of JSON'), - }, - }, - async ({ visibility, all, markdown }) => { - try { - const params = {}; - if (visibility) params.visibility = visibility; - const rows = all - ? await docs.getAll('/collections', params) - : ((await docs.get('/collections', params))?.collections?.items ?? []); - if (markdown) { - return okMarkdown(rows, [ - { key: 'id', header: 'ID' }, - { key: 'name', header: 'Name' }, - { key: 'visibility', header: 'Visibility' }, - { key: 'updatedAt', header: 'Updated' }, - ]); - } - return ok(rows); - } catch (e) { - return fail(e); - } - }, -); - -server.registerTool( - 'get_collection', - { - description: 'Get a Help Scout Docs collection by ID.', - inputSchema: { - id: z.string().describe('Collection ID'), - }, - }, - async ({ id }) => { - try { - const data = await docs.get(`/collections/${id}`); - return ok(data?.collection ?? data); - } catch (e) { - return fail(e); - } - }, -); +registerDocsTools(server, { docs, ok, okMarkdown, fail }); // ─── Start ──────────────────────────────────────────────────────────────────── diff --git a/test/docs-api-coverage.test.js b/test/docs-api-coverage.test.js new file mode 100644 index 0000000..e0e022b --- /dev/null +++ b/test/docs-api-coverage.test.js @@ -0,0 +1,76 @@ +/** + * Ensures docs/docs-api-endpoints.json matches implemented CLI commands and MCP tools. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +async function loadCatalog() { + const raw = await readFile(path.join(ROOT, 'docs', 'docs-api-endpoints.json'), 'utf8'); + return JSON.parse(raw); +} + +function flattenEndpoints(catalog) { + return catalog.groups.flatMap((g) => g.endpoints); +} + +async function loadMcpDocsTools() { + const source = await readFile(path.join(ROOT, 'src', 'mcp-server-docs.js'), 'utf8'); + const matches = [...source.matchAll(/registerTool\(\s*\n\s*'([a-z_]+)'/g)]; + return new Set(matches.map((m) => m[1])); +} + +test('all Docs API endpoints are marked implemented in catalog', async () => { + const catalog = await loadCatalog(); + const endpoints = flattenEndpoints(catalog); + assert.equal(endpoints.length, 39); + + const missing = endpoints.filter((e) => !e.implemented); + assert.deepEqual( + missing.map((e) => e.id), + [], + ); +}); + +test('every catalog endpoint has cliCommand and mcpTool', async () => { + const catalog = await loadCatalog(); + const endpoints = flattenEndpoints(catalog); + + for (const endpoint of endpoints) { + assert.ok(endpoint.cliCommand, `${endpoint.id} missing cliCommand`); + assert.ok(endpoint.mcpTool, `${endpoint.id} missing mcpTool`); + } +}); + +test('catalog mcpTool names exist in mcp-server-docs.js', async () => { + const catalog = await loadCatalog(); + const mcpTools = await loadMcpDocsTools(); + const endpoints = flattenEndpoints(catalog); + + for (const endpoint of endpoints) { + assert.ok( + mcpTools.has(endpoint.mcpTool), + `MCP tool ${endpoint.mcpTool} for ${endpoint.id} not found in mcp-server-docs.js`, + ); + } + + assert.equal(mcpTools.size, 39); +}); + +test('CLI docs commands are registered in bin/helpscout.js', async () => { + const binSource = await readFile(path.join(ROOT, 'bin', 'helpscout.js'), 'utf8'); + for (const cmd of [ + 'makeArticleCommand', + 'makeCollectionCommand', + 'makeCategoryCommand', + 'makeRedirectCommand', + 'makeSiteCommand', + 'makeAssetCommand', + ]) { + assert.match(binSource, new RegExp(cmd)); + } +}); diff --git a/test/docs-client.test.js b/test/docs-client.test.js index 5a17967..de60d1b 100644 --- a/test/docs-client.test.js +++ b/test/docs-client.test.js @@ -92,3 +92,69 @@ test('docs.get sends correct Basic Auth header', async () => { const expected = 'Basic ' + Buffer.from('mykey:X').toString('base64'); assert.equal(fetchStub.mock.calls[0].arguments[1].headers.Authorization, expected); }); + +function makeEmptyBodyFetchStub(responses) { + let callIndex = 0; + return mock.fn(async () => { + const resp = responses[callIndex++] ?? responses[responses.length - 1]; + const text = resp.text ?? (resp.body !== undefined ? JSON.stringify(resp.body) : ''); + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + headers: { get: (h) => resp.headers?.[h] ?? null }, + json: async () => (text.trim() ? JSON.parse(text) : null), + text: async () => text, + }; + }); +} + +test('docs.post returns id from Location header on 201 empty body', async () => { + globalThis.fetch = makeEmptyBodyFetchStub([ + { + status: 201, + headers: { Location: 'https://docsapi.helpscout.net/v1/articles/abc123' }, + text: '', + }, + ]); + process.env.HELPSCOUT_API_KEY = 'test-api-key'; + + const { docs } = await import('../src/docs-client.js'); + const result = await docs.post('/articles', { collectionId: 'c1', name: 'Test' }); + assert.deepEqual(result, { + id: 'abc123', + location: 'https://docsapi.helpscout.net/v1/articles/abc123', + }); +}); + +test('docs.post passes reload=true query param', async () => { + const fetchStub = makeEmptyBodyFetchStub([ + { + status: 201, + body: { article: { id: 'full-id', name: 'Reloaded' } }, + }, + ]); + globalThis.fetch = fetchStub; + process.env.HELPSCOUT_API_KEY = 'test-api-key'; + + const { docs } = await import('../src/docs-client.js'); + const result = await docs.post( + '/articles', + { collectionId: 'c1', name: 'Test' }, + { reload: 'true' }, + ); + assert.deepEqual(result, { id: 'full-id', name: 'Reloaded' }); + const url = fetchStub.mock.calls[0].arguments[0]; + assert.match(url, /reload=true/); +}); + +test('docs.upload returns JSON body for asset upload', async () => { + const assetBody = { filelink: 'https://cdn.example.com/img.png', filename: 'img.png' }; + globalThis.fetch = makeEmptyBodyFetchStub([{ status: 201, body: assetBody }]); + process.env.HELPSCOUT_API_KEY = 'test-api-key'; + + const { docs } = await import('../src/docs-client.js'); + const form = new FormData(); + form.append('file', new Blob(['x']), 'img.png'); + const result = await docs.upload('/assets/article', form); + assert.deepEqual(result, assetBody); +}); diff --git a/test/http.test.js b/test/http.test.js new file mode 100644 index 0000000..d475c67 --- /dev/null +++ b/test/http.test.js @@ -0,0 +1,72 @@ +/** + * Tests for src/http.js + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + parseLocationId, + parseCreatedResponse, + unwrapSingleItemEnvelope, + normalizeWriteResponse, +} from '../src/http.js'; + +function mockResponse({ status = 200, headers = {} }) { + return { + status, + headers: { + get: (name) => headers[name] ?? null, + }, + }; +} + +test('parseLocationId extracts last path segment', () => { + assert.equal( + parseLocationId('https://docsapi.helpscout.net/v1/articles/63754159b00eaf68a3f3de6c'), + '63754159b00eaf68a3f3de6c', + ); +}); + +test('unwrapSingleItemEnvelope unwraps nested resource', () => { + assert.deepEqual(unwrapSingleItemEnvelope({ article: { id: 'abc', name: 'Test' } }), { + id: 'abc', + name: 'Test', + }); +}); + +test('parseCreatedResponse returns Location id for Docs 201', () => { + const res = mockResponse({ + status: 201, + headers: { Location: 'https://docsapi.helpscout.net/v1/articles/abc123' }, + }); + assert.deepEqual(parseCreatedResponse(res, null), { + id: 'abc123', + location: 'https://docsapi.helpscout.net/v1/articles/abc123', + }); +}); + +test('parseCreatedResponse returns Resource-ID for Mailbox 201', () => { + const res = mockResponse({ + status: 201, + headers: { + 'Resource-ID': '123', + Location: 'https://api.helpscout.net/v2/conversations/123', + }, + }); + assert.deepEqual(parseCreatedResponse(res, null), { + id: 123, + location: 'https://api.helpscout.net/v2/conversations/123', + }); +}); + +test('parseCreatedResponse prefers JSON body when present', () => { + const res = mockResponse({ status: 201 }); + const body = { article: { id: 'from-body', name: 'Article' } }; + assert.deepEqual(parseCreatedResponse(res, body), { id: 'from-body', name: 'Article' }); +}); + +test('normalizeWriteResponse returns id object from create response', () => { + assert.deepEqual(normalizeWriteResponse({ id: 'abc', location: 'https://example.com/abc' }), { + id: 'abc', + location: 'https://example.com/abc', + }); +}); diff --git a/test/integration/docs.test.js b/test/integration/docs.test.js index 7312c31..4b115b8 100644 --- a/test/integration/docs.test.js +++ b/test/integration/docs.test.js @@ -36,3 +36,24 @@ test('search_articles with a broad query returns results', { skip }, async () => const articles = result?.articles?.items ?? []; assert.ok(Array.isArray(articles)); }); + +test('article create returns id from Location header', { skip }, async () => { + const { docs } = await import('../../src/docs-client.js'); + const collectionsResult = await docs.get('/collections'); + const collections = collectionsResult?.collections?.items ?? []; + if (collections.length === 0) return; + + const collectionId = collections[0].id; + const created = await docs.post('/articles', { + collectionId, + name: `CLI integration test ${Date.now()}`, + status: 'notpublished', + }); + assert.ok(created?.id, 'create should return id from Location header'); + + const fetched = await docs.get(`/articles/${created.id}`); + const article = fetched?.article ?? fetched; + assert.equal(article.id, created.id); + + await docs.delete(`/articles/${created.id}`); +}); diff --git a/test/mailbox-client.test.js b/test/mailbox-client.test.js index 29c1172..a8612e9 100644 --- a/test/mailbox-client.test.js +++ b/test/mailbox-client.test.js @@ -147,3 +147,42 @@ test('mailbox requests include correct User-Agent header', async () => { await mailbox.get('/conversations'); assert.equal(fetchStub.mock.calls[0].arguments[1].headers['User-Agent'], USER_AGENT); }); + +function makeEmptyBodyFetchStub(responses) { + let callIndex = 0; + return mock.fn(async () => { + const resp = responses[callIndex++] ?? responses[responses.length - 1]; + const text = resp.text ?? (resp.body !== undefined ? JSON.stringify(resp.body) : ''); + return { + ok: resp.ok ?? true, + status: resp.status ?? 200, + headers: { get: (h) => resp.headers?.[h] ?? null }, + json: async () => (text.trim() ? JSON.parse(text) : null), + text: async () => text, + }; + }); +} + +test('mailbox.post returns id from Resource-ID header on 201 empty body', async () => { + globalThis.fetch = makeEmptyBodyFetchStub([ + { + status: 201, + headers: { + 'Resource-ID': '456', + Location: 'https://api.helpscout.net/v2/conversations/456', + }, + text: '', + }, + ]); + + const { mailbox } = await import('../src/mailbox-client.js'); + const result = await mailbox.post('/conversations', { + subject: 'Test', + mailboxId: 1, + customer: { email: 'a@b.com' }, + }); + assert.deepEqual(result, { + id: 456, + location: 'https://api.helpscout.net/v2/conversations/456', + }); +});