
Hubspot
- 6 installs
- Updated January 29, 2026
- skillhq/hubspot
Helps with ai & agent building tasks.
About
hubspot is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- hubspot
- AI & Agent Building
- AI-coding skill
Hubspot by the numbers
- 6 all-time installs (skills.sh)
- Ranked #12,756 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 27, 2026 (Skillselion catalog sync)
npx skills add https://github.com/skillhq/hubspot --skill hubspotAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 6 |
|---|---|
| Last updated | January 29, 2026 |
| Repository | skillhq/hubspot ↗ |
What it does
Helps with ai & agent building tasks.
Files
HubSpot CLI
HubSpot CRM CLI for managing contacts, companies, deals, and engagements.
When to Use
Use this skill when the user:
- Asks to look up a contact, company, or deal in HubSpot
- Wants to search HubSpot CRM for specific records
- Needs to create or update contacts, deals, or companies
- Asks about deal pipelines or stages
- Wants to view or create notes/tasks
- Needs to check associations between CRM objects
Install
npm install -g @skillhq/hubspotAuthentication
First-time setup requires a Private App access token from HubSpot:
1. Go to HubSpot Settings > Integrations > Private Apps (under "Legacy Apps") 2. Create a new Private App with required scopes 3. Run hubspot auth and paste the access token (starts with pat-)
hubspot authCommands
Auth & Status
hubspot auth # Configure access token
hubspot check # Verify authentication
hubspot whoami # Show portal infoContacts
hubspot contacts # List contacts
hubspot contacts -n 50 # List 50 contacts
hubspot contact <id> # Get contact details
hubspot contact-search "john" # Search contacts
hubspot contact-create --email x@y.com --firstname John
hubspot contact-update <id> --phone "555-1234"Companies
hubspot companies # List companies
hubspot company <id> # Get company details
hubspot company-search "acme" # Search companiesDeals
hubspot deals # List deals
hubspot deals --pipeline <id> # Filter by pipeline ID
hubspot deals --pipeline-name "WCP" # Filter by pipeline name (fuzzy match)
hubspot deals --stage <id> # Filter by stage ID
hubspot deal <id> # Get deal details
hubspot deal-search "enterprise" # Search deals
hubspot pipelines # List pipelines and stages
hubspot pipelines --search "wallet" # Search pipelines by nameTickets
hubspot tickets # List tickets
hubspot ticket <id> # Get ticket details
hubspot ticket-search "issue" # Search ticketsNotes & Tasks
hubspot notes contacts <id> # List notes for contact
hubspot note-create contacts <id> "Note text"
hubspot tasks # List tasks
hubspot task <id> # Get task details
hubspot task-create --subject "Follow up" --due "2024-12-31" --priority HIGHAssociations
hubspot associations contacts <id> companies # List company associations
hubspot associate contacts <id1> deals <id2> # Create associationOutput Formats
All commands support:
- Default: Colored terminal output
--json: JSON output for scripting (clean, pipeable to jq)--markdown: Markdown table output
hubspot contacts --json # JSON format
hubspot deals --markdown # Markdown tablesJSON Output & Piping
JSON output is clean and can be piped directly to jq:
# Get pipeline ID for first deal
hubspot deals --json | jq '.results[0].pipeline'
# Filter deals by pipeline and extract names
hubspot deals --pipeline 831085590 --json | jq '.results[].dealname'
# Get contact emails
hubspot contacts --json | jq -r '.results[].email'
# Count deals in a stage
hubspot deals --pipeline 831085590 --stage 1231737429 --json | jq '.results | length'Note: Pagination info is included in the JSON response as .paging.next.after.
Pagination
List commands support pagination:
hubspot contacts -n 50 # Limit to 50 results
hubspot contacts --after <cursor> # Next page using cursorExamples
Check authentication:
hubspot checkSearch for a contact:
hubspot contact-search "john@example.com"View deal pipeline stages:
hubspot pipelinesCreate a task:
hubspot task-create --subject "Schedule demo" --priority HIGH --due "2024-12-15"Limitations
- Views not supported: The CLI cannot filter by HubSpot saved view IDs from URLs
- When given a view URL like
/views/57091019/, you must manually identify which pipeline the view filters and use--pipeline <id>instead - Use
hubspot pipelinesto list all pipelines and their IDs
View URL Workaround
If given a HubSpot view URL like https://app.hubspot.com/contacts/.../views/57091019/list:
1. Ask the user which pipeline it filters 2. Open the view in browser to identify the pipeline 3. Use hubspot pipelines and match by name
The view ID in the URL is not a pipeline ID - they are different concepts in HubSpot.
Notes
- IDs are HubSpot object IDs (numeric strings)
- Dates use ISO format (YYYY-MM-DD or full ISO timestamp)
- Priority values: LOW, MEDIUM, HIGH
- Task status values: NOT_STARTED, IN_PROGRESS, COMPLETED
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
registry-url: https://registry.npmjs.org
- run: npm ci
- run: npm run build
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Dependencies
node_modules/
# Build output
dist/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Config (contains tokens)
.hs-config.json5
# Environment
.env
.env.*
Agent Instructions
Releasing
When releasing a new version:
1. Always ask the user which version bump they want:
patch(0.0.x) - Bug fixes, small changesminor(0.x.0) - New features, backward compatiblemajor(x.0.0) - Breaking changes
2. Release process:
npm version <patch|minor|major>
git push && git push --tags3. The GitHub Actions workflow will automatically publish to npm when a v* tag is pushed.
CI/CD
- CI runs on every push/PR to
main- builds and validates - Publish runs on version tags (
v*) - publishes to npm with provenance
{
"name": "@skillhq/hubspot",
"version": "0.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@skillhq/hubspot",
"version": "0.3.1",
"license": "MIT",
"dependencies": {
"@hubspot/api-client": "^13.4.0",
"chalk": "^5.3.0",
"commander": "^14.0.0",
"json5": "^2.2.3",
"ora": "^8.1.0"
},
"bin": {
"hubspot": "dist/index.js"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@hubspot/api-client": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@hubspot/api-client/-/api-client-13.4.0.tgz",
"integrity": "sha512-B2Bu/F/nxzqvF0LlEIgA28G6ObANXkYosJSFLW3bdYXOOgH9kbVLJwPGze0H6L+dQJ+vmzZ1LeRpl5REXyHShA==",
"license": "ISC",
"dependencies": {
"@types/node": "*",
"@types/node-fetch": "^2.5.7",
"bottleneck": "^2.19.5",
"es6-promise": "^4.2.4",
"form-data": "^4.0.4",
"lodash.merge": "^4.6.2",
"node-fetch": "^2.6.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@types/node": {
"version": "22.19.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/bottleneck": {
"version": "2.19.5",
"resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz",
"integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==",
"license": "MIT"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"license": "MIT",
"dependencies": {
"restore-cursor": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cli-spinners": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz",
"integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"license": "MIT"
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-east-asian-width": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-function": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/onetime": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"license": "MIT",
"dependencies": {
"mimic-function": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"license": "MIT",
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
"integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
}
}
}
{
"name": "@skillhq/hubspot",
"version": "0.4.0",
"description": "HubSpot CRM CLI for managing contacts, companies, deals, and engagements",
"type": "module",
"main": "dist/index.js",
"bin": {
"hubspot": "dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
"prepublishOnly": "npm run build"
},
"keywords": [
"hubspot",
"crm",
"cli",
"contacts",
"deals"
],
"author": "Derek Rein",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/skillhq/hubspot.git"
},
"homepage": "https://github.com/skillhq/hubspot#readme",
"bugs": {
"url": "https://github.com/skillhq/hubspot/issues"
},
"dependencies": {
"@hubspot/api-client": "^13.4.0",
"chalk": "^5.3.0",
"commander": "^14.0.0",
"json5": "^2.2.3",
"ora": "^8.1.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
},
"files": [
"dist",
"SKILL.md"
]
}
HubSpot CLI
A fast, focused CLI for HubSpot CRM operations.
Installation
npm install -g @skillhq/hubspotOr from source:
git clone https://github.com/skillhq/hubspot.git
cd hubspot
npm install
npm run build
npm linkAuthentication
The CLI supports two authentication methods:
| OAuth 2.0 | Private App Token | |
|---|---|---|
| Best for | Teams | Personal use |
| Data access | User's permissions apply | Portal-wide access |
| Setup | One-time app creation, then each user logs in | Single token for one user |
| Token management | Auto-refreshes | Never expires |
Option 1: OAuth 2.0 (Recommended for Teams)
OAuth lets multiple team members authenticate with their own HubSpot accounts using a shared app. Each user can only access data they have permission to see in the HubSpot UI - their individual role and permissions apply.
First-time setup (one person creates the app):
1. Go to your HubSpot Developer Portal 2. Click Development in the left sidebar, then Legacy Apps 3. Click Create app and give it a name (e.g., "Team CLI") 4. Go to the Auth tab:
- Copy the Client ID and Client Secret
- Under Redirect URLs, add:
http://localhost:3847/callback - Under Scopes, add all required scopes (see below)
5. Share the Client ID and Client Secret with your team (via secure channel)
For each team member:
# Set credentials (one-time)
export HUBSPOT_CLIENT_ID=your-client-id
export HUBSPOT_CLIENT_SECRET=your-client-secret
# Login (opens browser for HubSpot authorization)
hubspot auth loginOr pass credentials directly:
hubspot auth login --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRETManaging OAuth sessions:
hubspot auth status # Check token expiry
hubspot auth logout # Clear credentialsOption 2: Private App Token (Simple, Single User)
For personal use or quick setup. Note: Private App Tokens have portal-wide access - they can see all data the scopes allow, regardless of individual user permissions.
1. In HubSpot, go to Settings > Integrations > Private Apps 2. Create a new Private App with required scopes (see below) 3. Copy the access token (starts with pat-) 4. Configure the CLI:
hubspot auth -t pat-your-token-here
# Or run `hubspot auth` and paste when promptedRequired Scopes
Add these scopes to your OAuth app or Private App:
| Scope | Purpose |
|---|---|
crm.objects.contacts.read | Read contacts |
crm.objects.contacts.write | Create/update contacts |
crm.objects.companies.read | Read companies |
crm.objects.companies.write | Create/update companies |
crm.objects.deals.read | Read deals |
crm.objects.deals.write | Create/update deals |
crm.objects.owners.read | List owners |
crm.schemas.contacts.read | Read contact properties |
crm.schemas.contacts.write | (OAuth only) Required for OAuth apps |
crm.schemas.companies.read | Read company properties |
crm.schemas.deals.read | Read deal properties |
tickets | Read/write tickets |
oauth | (OAuth only) Required for OAuth flow |
account-info.security.read | Read portal info |
Verify Connection
hubspot checkUsage
Contacts
hubspot contacts # List contacts
hubspot contact <id> # Get contact
hubspot contact-search "query" # Search
hubspot contact-create --email user@example.com --firstname John
hubspot contact-update <id> --lastname SmithCompanies
hubspot companies # List companies
hubspot company <id> # Get company
hubspot company-search "query" # SearchDeals
hubspot deals # List deals
hubspot deal <id> # Get deal
hubspot deal-search "query" # Search
hubspot pipelines # List pipelinesTickets
hubspot tickets # List tickets
hubspot ticket <id> # Get ticket
hubspot ticket-search "query" # SearchNotes & Tasks
hubspot notes <objectType> <id> # List notes
hubspot note-create <objectType> <id> "body" # Create note
hubspot tasks # List tasks
hubspot task <id> # Get task
hubspot task-create --subject "Task" --due "2024-12-31"Associations
hubspot associations <from> <id> <to> # List associations
hubspot associate <from> <id1> <to> <id2> # Create associationOutput Formats
- Default: Colored terminal output
--json: JSON for scripting--markdown: Markdown tables
Configuration
Config stored at ~/.config/hs/config.json5:
{
accessToken: "pat-xxx",
portalId: "12345678",
defaultFormat: "plain",
defaultLimit: 20
}License
MIT
import { Client } from '@hubspot/api-client';
import { AssociationSpecAssociationCategoryEnum } from '@hubspot/api-client/lib/codegen/crm/associations/v4/models/AssociationSpec.js';
import { FilterOperatorEnum } from '@hubspot/api-client/lib/codegen/crm/deals/models/Filter.js';
import {
getAccessToken,
isConfigured,
setPortalId,
getAuthMethod,
getOAuthCredentials,
setOAuthCredentials,
isTokenExpired,
getOAuthAppConfig,
isOAuthConfigured,
} from './config.js';
import { refreshAccessToken } from './oauth/flow.js';
import type {
Contact,
Company,
Deal,
Ticket,
Note,
Task,
Association,
Pipeline,
PortalInfo,
Owner,
PaginatedResult,
SearchOptions,
} from './types.js';
let clientInstance: Client | null = null;
/**
* Clears the cached client instance.
* Call this when credentials change (e.g., after token refresh or logout).
*/
export function resetClient(): void {
clientInstance = null;
}
/**
* Gets the HubSpot API client synchronously.
* For OAuth, use getClientAsync() instead to handle token refresh.
* @deprecated Use getClientAsync() for OAuth support with automatic token refresh.
*/
export function getClient(): Client {
if (clientInstance) {
return clientInstance;
}
const authMethod = getAuthMethod();
if (authMethod === 'oauth') {
const credentials = getOAuthCredentials();
if (!credentials) {
throw new Error('OAuth not configured. Run "hs auth login" to authenticate.');
}
// Note: This won't refresh tokens - use getClientAsync() for that
clientInstance = new Client({ accessToken: credentials.accessToken });
return clientInstance;
}
// Private App Token flow
if (!isConfigured()) {
throw new Error('Not configured. Run "hs auth" first to set up your access token.');
}
const accessToken = getAccessToken();
if (!accessToken) {
throw new Error('Access token not found. Run "hs auth" to configure.');
}
clientInstance = new Client({ accessToken });
return clientInstance;
}
/**
* Gets the HubSpot API client with automatic token refresh for OAuth.
* This is the preferred method when using OAuth authentication.
*/
export async function getClientAsync(): Promise<Client> {
const authMethod = getAuthMethod();
if (authMethod === 'oauth') {
if (!isOAuthConfigured()) {
throw new Error('OAuth not configured. Run "hs auth login" to authenticate.');
}
// Check if token needs refresh
if (isTokenExpired()) {
const credentials = getOAuthCredentials();
const appConfig = getOAuthAppConfig();
if (!credentials || !appConfig) {
throw new Error('OAuth configuration incomplete. Run "hs auth login" to re-authenticate.');
}
try {
const newCredentials = await refreshAccessToken(credentials.refreshToken, appConfig);
setOAuthCredentials(newCredentials);
resetClient(); // Clear cached client to use new token
} catch (error) {
throw new Error(`Failed to refresh token: ${error instanceof Error ? error.message : String(error)}. Run "hs auth login" to re-authenticate.`);
}
}
const credentials = getOAuthCredentials();
if (!credentials) {
throw new Error('OAuth credentials not found after refresh.');
}
if (!clientInstance) {
clientInstance = new Client({ accessToken: credentials.accessToken });
}
return clientInstance;
}
// Private App Token flow - no async operations needed
return getClient();
}
// Portal Info
export async function getPortalInfo(): Promise<PortalInfo> {
const client = getClient();
const response = await client.apiRequest({
method: 'GET',
path: '/account-info/v3/details',
});
const data = await response.json() as {
portalId: number;
timeZone: string;
utcOffsetMilliseconds: number;
currency: string;
additionalCurrencies: string[];
companyCurrency: string;
};
// Save portal ID to config
setPortalId(data.portalId.toString());
return {
portalId: data.portalId,
timeZone: data.timeZone,
currency: data.currency,
};
}
// Contacts
export async function getContacts(options: { limit?: number; after?: string; properties?: string[] } = {}): Promise<PaginatedResult<Contact>> {
const client = getClient();
const { limit = 20, after, properties = ['email', 'firstname', 'lastname', 'phone', 'company', 'jobtitle', 'lifecyclestage', 'createdate'] } = options;
const response = await client.crm.contacts.basicApi.getPage(limit, after, properties);
return {
results: response.results.map(r => ({
id: r.id,
email: r.properties.email ?? undefined,
firstname: r.properties.firstname ?? undefined,
lastname: r.properties.lastname ?? undefined,
phone: r.properties.phone ?? undefined,
company: r.properties.company ?? undefined,
jobtitle: r.properties.jobtitle ?? undefined,
lifecyclestage: r.properties.lifecyclestage ?? undefined,
createdate: r.properties.createdate ?? undefined,
lastmodifieddate: r.properties.hs_lastmodifieddate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getContact(id: string, properties?: string[]): Promise<Contact> {
const client = getClient();
const defaultProps = ['email', 'firstname', 'lastname', 'phone', 'company', 'jobtitle', 'lifecyclestage', 'hs_lead_status', 'createdate', 'hs_lastmodifieddate'];
const response = await client.crm.contacts.basicApi.getById(id, properties ?? defaultProps);
return {
id: response.id,
email: response.properties.email ?? undefined,
firstname: response.properties.firstname ?? undefined,
lastname: response.properties.lastname ?? undefined,
phone: response.properties.phone ?? undefined,
company: response.properties.company ?? undefined,
jobtitle: response.properties.jobtitle ?? undefined,
lifecyclestage: response.properties.lifecyclestage ?? undefined,
hs_lead_status: response.properties.hs_lead_status ?? undefined,
createdate: response.properties.createdate ?? undefined,
lastmodifieddate: response.properties.hs_lastmodifieddate ?? undefined,
properties: response.properties,
};
}
export async function searchContacts(query: string, options: SearchOptions = {}): Promise<PaginatedResult<Contact>> {
const client = getClient();
const { limit = 20, after, properties = ['email', 'firstname', 'lastname', 'phone', 'company', 'jobtitle'] } = options;
const response = await client.crm.contacts.searchApi.doSearch({
query,
limit,
after,
properties,
});
return {
results: response.results.map(r => ({
id: r.id,
email: r.properties.email ?? undefined,
firstname: r.properties.firstname ?? undefined,
lastname: r.properties.lastname ?? undefined,
phone: r.properties.phone ?? undefined,
company: r.properties.company ?? undefined,
jobtitle: r.properties.jobtitle ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function createContact(properties: Record<string, string>): Promise<Contact> {
const client = getClient();
const response = await client.crm.contacts.basicApi.create({ properties });
return {
id: response.id,
email: response.properties.email ?? undefined,
firstname: response.properties.firstname ?? undefined,
lastname: response.properties.lastname ?? undefined,
properties: response.properties,
};
}
export async function updateContact(id: string, properties: Record<string, string>): Promise<Contact> {
const client = getClient();
const response = await client.crm.contacts.basicApi.update(id, { properties });
return {
id: response.id,
email: response.properties.email ?? undefined,
firstname: response.properties.firstname ?? undefined,
lastname: response.properties.lastname ?? undefined,
properties: response.properties,
};
}
// Companies
export async function getCompanies(options: { limit?: number; after?: string; properties?: string[] } = {}): Promise<PaginatedResult<Company>> {
const client = getClient();
const { limit = 20, after, properties = ['name', 'domain', 'industry', 'phone', 'city', 'state', 'country', 'numberofemployees', 'annualrevenue', 'createdate'] } = options;
const response = await client.crm.companies.basicApi.getPage(limit, after, properties);
return {
results: response.results.map(r => ({
id: r.id,
name: r.properties.name ?? undefined,
domain: r.properties.domain ?? undefined,
industry: r.properties.industry ?? undefined,
phone: r.properties.phone ?? undefined,
city: r.properties.city ?? undefined,
state: r.properties.state ?? undefined,
country: r.properties.country ?? undefined,
numberofemployees: r.properties.numberofemployees ?? undefined,
annualrevenue: r.properties.annualrevenue ?? undefined,
createdate: r.properties.createdate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getCompany(id: string, properties?: string[]): Promise<Company> {
const client = getClient();
const defaultProps = ['name', 'domain', 'industry', 'phone', 'city', 'state', 'country', 'numberofemployees', 'annualrevenue', 'description', 'createdate', 'hs_lastmodifieddate'];
const response = await client.crm.companies.basicApi.getById(id, properties ?? defaultProps);
return {
id: response.id,
name: response.properties.name ?? undefined,
domain: response.properties.domain ?? undefined,
industry: response.properties.industry ?? undefined,
phone: response.properties.phone ?? undefined,
city: response.properties.city ?? undefined,
state: response.properties.state ?? undefined,
country: response.properties.country ?? undefined,
numberofemployees: response.properties.numberofemployees ?? undefined,
annualrevenue: response.properties.annualrevenue ?? undefined,
description: response.properties.description ?? undefined,
createdate: response.properties.createdate ?? undefined,
lastmodifieddate: response.properties.hs_lastmodifieddate ?? undefined,
properties: response.properties,
};
}
export async function searchCompanies(query: string, options: SearchOptions = {}): Promise<PaginatedResult<Company>> {
const client = getClient();
const { limit = 20, after, properties = ['name', 'domain', 'industry', 'city', 'state'] } = options;
const response = await client.crm.companies.searchApi.doSearch({
query,
limit,
after,
properties,
});
return {
results: response.results.map(r => ({
id: r.id,
name: r.properties.name ?? undefined,
domain: r.properties.domain ?? undefined,
industry: r.properties.industry ?? undefined,
city: r.properties.city ?? undefined,
state: r.properties.state ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
// Deals
export async function getDeals(options: { limit?: number; after?: string; properties?: string[] } = {}): Promise<PaginatedResult<Deal>> {
const client = getClient();
const { limit = 20, after, properties = ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate', 'createdate'] } = options;
const response = await client.crm.deals.basicApi.getPage(limit, after, properties);
return {
results: response.results.map(r => ({
id: r.id,
dealname: r.properties.dealname ?? undefined,
amount: r.properties.amount ?? undefined,
dealstage: r.properties.dealstage ?? undefined,
pipeline: r.properties.pipeline ?? undefined,
closedate: r.properties.closedate ?? undefined,
createdate: r.properties.createdate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getDeal(id: string, properties?: string[]): Promise<Deal> {
const client = getClient();
const defaultProps = ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate', 'hs_deal_stage_probability', 'dealtype', 'description', 'createdate', 'hs_lastmodifieddate'];
const response = await client.crm.deals.basicApi.getById(id, properties ?? defaultProps);
return {
id: response.id,
dealname: response.properties.dealname ?? undefined,
amount: response.properties.amount ?? undefined,
dealstage: response.properties.dealstage ?? undefined,
pipeline: response.properties.pipeline ?? undefined,
closedate: response.properties.closedate ?? undefined,
hs_deal_stage_probability: response.properties.hs_deal_stage_probability ?? undefined,
dealtype: response.properties.dealtype ?? undefined,
description: response.properties.description ?? undefined,
createdate: response.properties.createdate ?? undefined,
lastmodifieddate: response.properties.hs_lastmodifieddate ?? undefined,
properties: response.properties,
};
}
export async function searchDeals(query: string, options: SearchOptions = {}): Promise<PaginatedResult<Deal>> {
const client = getClient();
const { limit = 20, after, properties = ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate'], filters } = options;
// Build filter groups if filters are provided
const filterGroups = filters?.length
? [{ filters: filters.map(f => ({ propertyName: f.propertyName, operator: f.operator as FilterOperatorEnum, value: f.value })) }]
: undefined;
const response = await client.crm.deals.searchApi.doSearch({
query,
limit,
after,
properties,
filterGroups,
});
return {
results: response.results.map(r => ({
id: r.id,
dealname: r.properties.dealname ?? undefined,
amount: r.properties.amount ?? undefined,
dealstage: r.properties.dealstage ?? undefined,
pipeline: r.properties.pipeline ?? undefined,
closedate: r.properties.closedate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function filterDeals(options: SearchOptions & { pipeline?: string; stage?: string } = {}): Promise<PaginatedResult<Deal>> {
const client = getClient();
const { limit = 20, after, properties = ['dealname', 'amount', 'dealstage', 'pipeline', 'closedate'], pipeline, stage } = options;
// Build filters for pipeline and/or stage
const filters: Array<{ propertyName: string; operator: FilterOperatorEnum; value: string }> = [];
if (pipeline) {
filters.push({ propertyName: 'pipeline', operator: FilterOperatorEnum.Eq, value: pipeline });
}
if (stage) {
filters.push({ propertyName: 'dealstage', operator: FilterOperatorEnum.Eq, value: stage });
}
const filterGroups = filters.length > 0 ? [{ filters }] : undefined;
const response = await client.crm.deals.searchApi.doSearch({
limit,
after,
properties,
filterGroups,
});
return {
results: response.results.map(r => ({
id: r.id,
dealname: r.properties.dealname ?? undefined,
amount: r.properties.amount ?? undefined,
dealstage: r.properties.dealstage ?? undefined,
pipeline: r.properties.pipeline ?? undefined,
closedate: r.properties.closedate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getPipelines(): Promise<Pipeline[]> {
const client = getClient();
const response = await client.crm.pipelines.pipelinesApi.getAll('deals');
return response.results.map(p => ({
id: p.id,
label: p.label,
displayOrder: p.displayOrder,
stages: p.stages.map(s => ({
id: s.id,
label: s.label,
displayOrder: s.displayOrder,
metadata: s.metadata ?? {},
})),
}));
}
// Tickets
export async function getTickets(options: { limit?: number; after?: string; properties?: string[] } = {}): Promise<PaginatedResult<Ticket>> {
const client = getClient();
const { limit = 20, after, properties = ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority', 'createdate'] } = options;
const response = await client.crm.tickets.basicApi.getPage(limit, after, properties);
return {
results: response.results.map(r => ({
id: r.id,
subject: r.properties.subject ?? undefined,
content: r.properties.content ?? undefined,
hs_pipeline: r.properties.hs_pipeline ?? undefined,
hs_pipeline_stage: r.properties.hs_pipeline_stage ?? undefined,
hs_ticket_priority: r.properties.hs_ticket_priority ?? undefined,
createdate: r.properties.createdate ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getTicket(id: string, properties?: string[]): Promise<Ticket> {
const client = getClient();
const defaultProps = ['subject', 'content', 'hs_pipeline', 'hs_pipeline_stage', 'hs_ticket_priority', 'hs_ticket_category', 'createdate', 'hs_lastmodifieddate'];
const response = await client.crm.tickets.basicApi.getById(id, properties ?? defaultProps);
return {
id: response.id,
subject: response.properties.subject ?? undefined,
content: response.properties.content ?? undefined,
hs_pipeline: response.properties.hs_pipeline ?? undefined,
hs_pipeline_stage: response.properties.hs_pipeline_stage ?? undefined,
hs_ticket_priority: response.properties.hs_ticket_priority ?? undefined,
hs_ticket_category: response.properties.hs_ticket_category ?? undefined,
createdate: response.properties.createdate ?? undefined,
lastmodifieddate: response.properties.hs_lastmodifieddate ?? undefined,
properties: response.properties,
};
}
export async function searchTickets(query: string, options: SearchOptions = {}): Promise<PaginatedResult<Ticket>> {
const client = getClient();
const { limit = 20, after, properties = ['subject', 'content', 'hs_pipeline_stage', 'hs_ticket_priority'] } = options;
const response = await client.crm.tickets.searchApi.doSearch({
query,
limit,
after,
properties,
});
return {
results: response.results.map(r => ({
id: r.id,
subject: r.properties.subject ?? undefined,
content: r.properties.content ?? undefined,
hs_pipeline_stage: r.properties.hs_pipeline_stage ?? undefined,
hs_ticket_priority: r.properties.hs_ticket_priority ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
// Notes
export async function getNotes(objectType: string, objectId: string, options: { limit?: number; after?: string } = {}): Promise<PaginatedResult<Note>> {
const client = getClient();
const { limit = 20, after } = options;
// Get associated notes
const associations = await client.crm.associations.v4.basicApi.getPage(
objectType,
objectId,
'notes',
after,
limit
);
if (!associations.results.length) {
return { results: [] };
}
// Get note details
const noteIds = associations.results.map(a => a.toObjectId);
const notes = await Promise.all(
noteIds.map(id =>
client.crm.objects.notes.basicApi.getById(id, ['hs_note_body', 'hs_timestamp', 'hs_attachment_ids'])
)
);
return {
results: notes.map(n => ({
id: n.id,
hs_note_body: n.properties.hs_note_body ?? undefined,
hs_timestamp: n.properties.hs_timestamp ?? undefined,
hs_attachment_ids: n.properties.hs_attachment_ids ?? undefined,
properties: n.properties,
})),
};
}
export async function createNote(objectType: string, objectId: string, body: string): Promise<Note> {
const client = getClient();
// Create the note
const note = await client.crm.objects.notes.basicApi.create({
properties: {
hs_note_body: body,
hs_timestamp: new Date().toISOString(),
},
});
// Associate with object
await client.crm.associations.v4.basicApi.create(
'notes',
note.id,
objectType,
objectId,
[{ associationCategory: AssociationSpecAssociationCategoryEnum.HubspotDefined, associationTypeId: getAssociationTypeId('notes', objectType) }]
);
return {
id: note.id,
hs_note_body: note.properties.hs_note_body ?? undefined,
hs_timestamp: note.properties.hs_timestamp ?? undefined,
properties: note.properties,
};
}
// Tasks
export async function getTasks(options: { limit?: number; after?: string; properties?: string[] } = {}): Promise<PaginatedResult<Task>> {
const client = getClient();
const { limit = 20, after, properties = ['hs_task_subject', 'hs_task_body', 'hs_task_status', 'hs_task_priority', 'hs_timestamp'] } = options;
const response = await client.crm.objects.tasks.basicApi.getPage(limit, after, properties);
return {
results: response.results.map(r => ({
id: r.id,
hs_task_subject: r.properties.hs_task_subject ?? undefined,
hs_task_body: r.properties.hs_task_body ?? undefined,
hs_task_status: r.properties.hs_task_status ?? undefined,
hs_task_priority: r.properties.hs_task_priority ?? undefined,
hs_timestamp: r.properties.hs_timestamp ?? undefined,
properties: r.properties,
})),
paging: response.paging,
};
}
export async function getTask(id: string, properties?: string[]): Promise<Task> {
const client = getClient();
const defaultProps = ['hs_task_subject', 'hs_task_body', 'hs_task_status', 'hs_task_priority', 'hs_task_type', 'hs_timestamp'];
const response = await client.crm.objects.tasks.basicApi.getById(id, properties ?? defaultProps);
return {
id: response.id,
hs_task_subject: response.properties.hs_task_subject ?? undefined,
hs_task_body: response.properties.hs_task_body ?? undefined,
hs_task_status: response.properties.hs_task_status ?? undefined,
hs_task_priority: response.properties.hs_task_priority ?? undefined,
hs_task_type: response.properties.hs_task_type ?? undefined,
hs_timestamp: response.properties.hs_timestamp ?? undefined,
properties: response.properties,
};
}
export async function createTask(properties: {
subject: string;
body?: string;
dueDate?: string;
priority?: 'LOW' | 'MEDIUM' | 'HIGH';
status?: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED';
}): Promise<Task> {
const client = getClient();
const taskProperties: Record<string, string> = {
hs_task_subject: properties.subject,
hs_task_status: properties.status ?? 'NOT_STARTED',
};
if (properties.body) {
taskProperties.hs_task_body = properties.body;
}
if (properties.dueDate) {
taskProperties.hs_timestamp = properties.dueDate;
}
if (properties.priority) {
taskProperties.hs_task_priority = properties.priority;
}
const response = await client.crm.objects.tasks.basicApi.create({ properties: taskProperties });
return {
id: response.id,
hs_task_subject: response.properties.hs_task_subject ?? undefined,
hs_task_body: response.properties.hs_task_body ?? undefined,
hs_task_status: response.properties.hs_task_status ?? undefined,
hs_task_priority: response.properties.hs_task_priority ?? undefined,
properties: response.properties,
};
}
// Associations
export async function getAssociations(fromType: string, fromId: string, toType: string): Promise<Association[]> {
const client = getClient();
const response = await client.crm.associations.v4.basicApi.getPage(fromType, fromId, toType);
return response.results.map(r => ({
fromObjectType: fromType,
fromObjectId: fromId,
toObjectType: toType,
toObjectId: r.toObjectId,
associationTypes: r.associationTypes.map(t => ({
category: t.category,
typeId: t.typeId,
label: t.label ?? undefined,
})),
}));
}
export async function createAssociation(fromType: string, fromId: string, toType: string, toId: string): Promise<void> {
const client = getClient();
const typeId = getAssociationTypeId(fromType, toType);
await client.crm.associations.v4.basicApi.create(
fromType,
fromId,
toType,
toId,
[{ associationCategory: AssociationSpecAssociationCategoryEnum.HubspotDefined, associationTypeId: typeId }]
);
}
// Helper to get association type ID
function getAssociationTypeId(fromType: string, toType: string): number {
// Common HubSpot association type IDs
const associations: Record<string, Record<string, number>> = {
contacts: { companies: 1, deals: 3, tickets: 15 },
companies: { contacts: 2, deals: 5, tickets: 25 },
deals: { contacts: 4, companies: 6, tickets: 27 },
tickets: { contacts: 16, companies: 26, deals: 28 },
notes: { contacts: 202, companies: 190, deals: 214, tickets: 226 },
tasks: { contacts: 204, companies: 192, deals: 216, tickets: 228 },
};
return associations[fromType]?.[toType] ?? 0;
}
// Owners
export async function getOwners(): Promise<Owner[]> {
const client = getClient();
const response = await client.crm.owners.ownersApi.getPage();
return response.results.map(o => ({
id: o.id,
email: o.email ?? '',
firstName: o.firstName ?? undefined,
lastName: o.lastName ?? undefined,
userId: o.userId ?? undefined,
}));
}
import { Command } from 'commander';
import { getAssociations, createAssociation } from '../client.js';
import { formatJson, formatAssociations, formatAssociationsMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner, succeedSpinner } from '../formatters/index.js';
export const associationsCommand = new Command('associations')
.description('List associations for an object')
.argument('<fromType>', 'From object type (contacts, companies, deals, tickets)')
.argument('<id>', 'Object ID')
.argument('<toType>', 'To object type (contacts, companies, deals, tickets)')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (fromType, id, toType, options) => {
const spinner = createSpinner('Fetching associations...', options);
try {
const associations = await getAssociations(fromType, id, toType);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(associations));
break;
case 'markdown':
console.log(formatAssociationsMarkdown(associations));
break;
default:
console.log(formatAssociations(associations));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch associations');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const associateCommand = new Command('associate')
.description('Create an association between two objects')
.argument('<fromType>', 'From object type (contacts, companies, deals, tickets)')
.argument('<fromId>', 'From object ID')
.argument('<toType>', 'To object type (contacts, companies, deals, tickets)')
.argument('<toId>', 'To object ID')
.option('--json', 'Output as JSON')
.action(async (fromType, fromId, toType, toId, options) => {
const spinner = createSpinner('Creating association...', options);
try {
await createAssociation(fromType, fromId, toType, toId);
succeedSpinner(spinner, 'Association created!');
console.log(`${fromType}/${fromId} -> ${toType}/${toId}`);
} catch (error) {
failSpinner(spinner, 'Failed to create association');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import * as readline from 'node:readline';
import {
isConfigured,
setAccessToken,
getConfigPath,
getAuthMethod,
setAuthMethod,
getOAuthCredentials,
setOAuthCredentials,
clearOAuthCredentials,
getOAuthAppConfig,
setOAuthAppConfig,
isTokenExpired,
getTimeUntilExpiry,
isOAuthConfigured,
} from '../config.js';
import { performOAuthFlow, DEFAULT_SCOPES } from '../oauth/index.js';
import { resetClient } from '../client.js';
import type { OAuthAppConfig } from '../types.js';
async function promptForToken(): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question('Enter your HubSpot Private App access token: ', (answer) => {
rl.close();
resolve(answer.trim());
});
});
}
function formatTimeRemaining(ms: number): string {
if (ms <= 0) return 'expired';
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
if (minutes > 0) {
return `${minutes} minutes`;
}
return `${seconds} seconds`;
}
// Main auth command - Private App Token flow (backward compatible)
export const authCommand = new Command('auth')
.description('Configure HubSpot authentication (Private App Token or OAuth)')
.option('-t, --token <token>', 'Private App access token (or enter interactively)')
.action(async (options) => {
// If no subcommand and no flags, show help
if (!options.token && process.argv.length === 3) {
// Just "hubspotauth" - show current status and help
console.log('HubSpot CLI Authentication\n');
const authMethod = getAuthMethod();
if (authMethod === 'oauth' && isOAuthConfigured()) {
console.log('Currently authenticated with OAuth.');
console.log('Run "hubspotauth status" for details.\n');
} else if (isConfigured()) {
console.log('Currently authenticated with Private App Token.');
console.log('Run "hubspotcheck" to verify the connection.\n');
} else {
console.log('Not currently authenticated.\n');
}
console.log('Authentication methods:');
console.log(' hubspotauth Configure Private App Token (interactive)');
console.log(' hubspotauth -t <token> Configure Private App Token (non-interactive)');
console.log(' hubspotauth login Authenticate with OAuth 2.0');
console.log(' hubspotauth logout Clear OAuth credentials');
console.log(' hubspotauth status Show current authentication status\n');
return;
}
// Private App Token flow
if (isConfigured() && !options.token) {
console.log('Already configured with Private App Token.');
console.log('Run "hubspotcheck" to verify your connection.');
console.log(`To re-authenticate, delete ${getConfigPath()} and run "hubspotauth" again.`);
return;
}
console.log('HubSpot CLI Authentication - Private App Token\n');
console.log('To get a Private App access token:');
console.log('1. Go to your HubSpot account Settings');
console.log('2. Navigate to Integrations > Private Apps (under "Legacy Apps")');
console.log('3. Create a new Private App with these scopes:\n');
console.log(' CRM:');
console.log(' - crm.objects.contacts.read / write');
console.log(' - crm.objects.companies.read / write');
console.log(' - crm.objects.deals.read / write');
console.log(' - crm.objects.owners.read');
console.log(' - crm.schemas.contacts.read (for custom properties)');
console.log(' - crm.schemas.companies.read');
console.log(' - crm.schemas.deals.read\n');
console.log(' Tickets:');
console.log(' - tickets (read/write)\n');
console.log(' Settings:');
console.log(' - account-info.security.read (for portal info)\n');
console.log('4. Copy the access token (starts with pat-)\n');
try {
const token = options.token || await promptForToken();
if (!token) {
console.error('No token provided. Aborting.');
process.exit(1);
}
// Basic validation - HubSpot tokens start with 'pat-'
if (!token.startsWith('pat-')) {
console.warn('Warning: Token does not start with "pat-". Make sure you\'re using a Private App token.');
}
setAccessToken(token);
setAuthMethod('token');
resetClient();
console.log('\nAccess token saved successfully!');
console.log('Run "hubspotcheck" to verify the connection.');
} catch (error) {
console.error('Authentication failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// OAuth Login subcommand
export const authLoginCommand = new Command('login')
.description('Authenticate with HubSpot using OAuth 2.0')
.option('--client-id <id>', 'OAuth Client ID (or set HUBSPOT_CLIENT_ID env var)')
.option('--client-secret <secret>', 'OAuth Client Secret (or set HUBSPOT_CLIENT_SECRET env var)')
.action(async (options) => {
console.log('HubSpot CLI Authentication - OAuth 2.0\n');
// Get client credentials from options, env vars, or saved config
const clientId = options.clientId || process.env.HUBSPOT_CLIENT_ID || getOAuthAppConfig()?.clientId;
const clientSecret = options.clientSecret || process.env.HUBSPOT_CLIENT_SECRET || getOAuthAppConfig()?.clientSecret;
if (!clientId || !clientSecret) {
console.log('OAuth requires a HubSpot App with Client ID and Secret.\n');
console.log('To create a HubSpot App:');
console.log('1. Go to https://developers.hubspot.com/');
console.log('2. Create or select an App');
console.log('3. Go to App Settings > Auth');
console.log('4. Copy the Client ID and Client Secret');
console.log('5. Add redirect URI: http://localhost:3847/callback\n');
console.log('Then run:');
console.log(' hubspotauth login --client-id <YOUR_CLIENT_ID> --client-secret <YOUR_SECRET>\n');
console.log('Or set environment variables:');
console.log(' export HUBSPOT_CLIENT_ID=<YOUR_CLIENT_ID>');
console.log(' export HUBSPOT_CLIENT_SECRET=<YOUR_SECRET>');
console.log(' hubspotauth login\n');
process.exit(1);
}
const appConfig: OAuthAppConfig = { clientId, clientSecret };
try {
console.log('Starting OAuth login flow...');
console.log('A browser window will open for HubSpot authorization.\n');
const credentials = await performOAuthFlow(appConfig, DEFAULT_SCOPES);
// Save credentials and app config
setOAuthCredentials(credentials);
setOAuthAppConfig(appConfig);
resetClient();
console.log('\nOAuth authentication successful!');
console.log(`Token expires in: ${formatTimeRemaining(getTimeUntilExpiry())}`);
console.log('Run "hubspotcheck" to verify the connection.');
} catch (error) {
console.error('OAuth authentication failed:', error instanceof Error ? error.message : error);
process.exit(1);
}
});
// OAuth Logout subcommand
export const authLogoutCommand = new Command('logout')
.description('Clear OAuth credentials and logout')
.action(() => {
if (!isOAuthConfigured()) {
console.log('Not currently authenticated with OAuth.');
return;
}
clearOAuthCredentials();
resetClient();
console.log('OAuth credentials cleared successfully.');
console.log('You have been logged out.');
});
// Auth Status subcommand
export const authStatusCommand = new Command('status')
.description('Show current authentication status')
.action(() => {
console.log('Authentication Status\n');
const authMethod = getAuthMethod();
if (authMethod === 'oauth' && isOAuthConfigured()) {
const credentials = getOAuthCredentials();
const timeRemaining = getTimeUntilExpiry();
const expired = isTokenExpired();
console.log(` Method: OAuth 2.0`);
console.log(` Token status: ${expired ? 'Expired (will refresh on next request)' : 'Valid'}`);
console.log(` Token expires: ${expired ? 'now' : `in ${formatTimeRemaining(timeRemaining)}`}`);
if (credentials?.scopes?.length) {
console.log(` Scopes: ${credentials.scopes.join(', ')}`);
}
} else if (isConfigured()) {
console.log(` Method: Private App Token`);
console.log(` Status: Configured`);
} else {
console.log(' Status: Not authenticated');
console.log('\nTo authenticate:');
console.log(' hubspotauth - Use Private App Token');
console.log(' hubspotauth login - Use OAuth 2.0');
}
console.log(`\nConfig file: ${getConfigPath()}`);
});
// Add subcommands to main auth command
authCommand.addCommand(authLoginCommand);
authCommand.addCommand(authLogoutCommand);
authCommand.addCommand(authStatusCommand);
import { Command } from 'commander';
import { isConfigured } from '../config.js';
import { getPortalInfo } from '../client.js';
import { createSpinner, failSpinner, succeedSpinner } from '../formatters/index.js';
import chalk from 'chalk';
export const checkCommand = new Command('check')
.description('Verify HubSpot authentication')
.action(async () => {
if (!isConfigured()) {
console.error('Not configured. Run "hs auth" first.');
process.exit(1);
}
const spinner = createSpinner('Checking HubSpot connection...', {});
try {
const info = await getPortalInfo();
succeedSpinner(spinner, 'Connection successful!');
console.log(` Portal ID: ${chalk.cyan(info.portalId)}`);
console.log(` Timezone: ${info.timeZone}`);
console.log(` Currency: ${info.currency}`);
} catch (error) {
failSpinner(spinner, 'Connection failed');
if (error instanceof Error) {
if (error.message.includes('401')) {
console.error('Invalid or expired access token. Run "hs auth" to reconfigure.');
} else {
console.error(error.message);
}
}
process.exit(1);
}
});
import { Command } from 'commander';
import { getCompanies, getCompany, searchCompanies } from '../client.js';
import { formatJson, formatCompanies, formatCompany, formatCompaniesMarkdown, formatCompanyMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner } from '../formatters/index.js';
export const companiesCommand = new Command('companies')
.description('List companies')
.option('-n, --limit <number>', 'Maximum number of companies', '20')
.option('--after <cursor>', 'Pagination cursor')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching companies...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const result = await getCompanies({
limit: parseInt(options.limit),
after: options.after,
properties,
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatCompaniesMarkdown(result.results));
break;
default:
console.log(formatCompanies(result.results));
}
// Only show pagination hint for non-JSON output (JSON includes paging in response)
if (result.paging?.next?.after && format !== 'json') {
console.log(`\nNext page: --after ${result.paging.next.after}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch companies');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const companyCommand = new Command('company')
.description('Get company by ID')
.argument('<id>', 'Company ID')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const spinner = createSpinner('Fetching company...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const company = await getCompany(id, properties);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(company));
break;
case 'markdown':
console.log(formatCompanyMarkdown(company));
break;
default:
console.log(formatCompany(company));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch company');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const companySearchCommand = new Command('company-search')
.description('Search companies')
.argument('<query>', 'Search query')
.option('-n, --limit <number>', 'Maximum results', '20')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (query, options) => {
const spinner = createSpinner('Searching companies...', options);
try {
const result = await searchCompanies(query, {
limit: parseInt(options.limit),
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatCompaniesMarkdown(result.results));
break;
default:
console.log(formatCompanies(result.results));
}
} catch (error) {
failSpinner(spinner, 'Failed to search companies');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import { getContacts, getContact, searchContacts, createContact, updateContact } from '../client.js';
import { formatJson, formatContacts, formatContact, formatContactsMarkdown, formatContactMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner, succeedSpinner } from '../formatters/index.js';
export const contactsCommand = new Command('contacts')
.description('List contacts')
.option('-n, --limit <number>', 'Maximum number of contacts', '20')
.option('--after <cursor>', 'Pagination cursor')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching contacts...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const result = await getContacts({
limit: parseInt(options.limit),
after: options.after,
properties,
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatContactsMarkdown(result.results));
break;
default:
console.log(formatContacts(result.results));
}
// Only show pagination hint for non-JSON output (JSON includes paging in response)
if (result.paging?.next?.after && format !== 'json') {
console.log(`\nNext page: --after ${result.paging.next.after}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch contacts');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const contactCommand = new Command('contact')
.description('Get contact by ID')
.argument('<id>', 'Contact ID')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const spinner = createSpinner('Fetching contact...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const contact = await getContact(id, properties);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(contact));
break;
case 'markdown':
console.log(formatContactMarkdown(contact));
break;
default:
console.log(formatContact(contact));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch contact');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const contactSearchCommand = new Command('contact-search')
.description('Search contacts')
.argument('<query>', 'Search query')
.option('-n, --limit <number>', 'Maximum results', '20')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (query, options) => {
const spinner = createSpinner('Searching contacts...', options);
try {
const result = await searchContacts(query, {
limit: parseInt(options.limit),
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatContactsMarkdown(result.results));
break;
default:
console.log(formatContacts(result.results));
}
} catch (error) {
failSpinner(spinner, 'Failed to search contacts');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const contactCreateCommand = new Command('contact-create')
.description('Create a new contact')
.option('--email <email>', 'Email address')
.option('--firstname <name>', 'First name')
.option('--lastname <name>', 'Last name')
.option('--phone <phone>', 'Phone number')
.option('--company <company>', 'Company name')
.option('--jobtitle <title>', 'Job title')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const properties: Record<string, string> = {};
if (options.email) properties.email = options.email;
if (options.firstname) properties.firstname = options.firstname;
if (options.lastname) properties.lastname = options.lastname;
if (options.phone) properties.phone = options.phone;
if (options.company) properties.company = options.company;
if (options.jobtitle) properties.jobtitle = options.jobtitle;
if (Object.keys(properties).length === 0) {
console.error('At least one property is required (--email, --firstname, etc.)');
process.exit(1);
}
const spinner = createSpinner('Creating contact...', options);
try {
const contact = await createContact(properties);
succeedSpinner(spinner, 'Contact created!');
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(contact));
break;
case 'markdown':
console.log(formatContactMarkdown(contact));
break;
default:
console.log(formatContact(contact));
}
} catch (error) {
failSpinner(spinner, 'Failed to create contact');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const contactUpdateCommand = new Command('contact-update')
.description('Update a contact')
.argument('<id>', 'Contact ID')
.option('--email <email>', 'Email address')
.option('--firstname <name>', 'First name')
.option('--lastname <name>', 'Last name')
.option('--phone <phone>', 'Phone number')
.option('--company <company>', 'Company name')
.option('--jobtitle <title>', 'Job title')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const properties: Record<string, string> = {};
if (options.email) properties.email = options.email;
if (options.firstname) properties.firstname = options.firstname;
if (options.lastname) properties.lastname = options.lastname;
if (options.phone) properties.phone = options.phone;
if (options.company) properties.company = options.company;
if (options.jobtitle) properties.jobtitle = options.jobtitle;
if (Object.keys(properties).length === 0) {
console.error('At least one property to update is required');
process.exit(1);
}
const spinner = createSpinner('Updating contact...', options);
try {
const contact = await updateContact(id, properties);
succeedSpinner(spinner, 'Contact updated!');
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(contact));
break;
case 'markdown':
console.log(formatContactMarkdown(contact));
break;
default:
console.log(formatContact(contact));
}
} catch (error) {
failSpinner(spinner, 'Failed to update contact');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import { getDeals, getDeal, searchDeals, filterDeals, getPipelines } from '../client.js';
import { formatJson, formatDeals, formatDeal, formatDealsMarkdown, formatDealMarkdown, formatPipelines, formatPipelinesMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner } from '../formatters/index.js';
export const dealsCommand = new Command('deals')
.description('List deals')
.option('-n, --limit <number>', 'Maximum number of deals', '20')
.option('--after <cursor>', 'Pagination cursor')
.option('--pipeline <id>', 'Filter by pipeline ID')
.option('--pipeline-name <name>', 'Filter by pipeline name (case-insensitive, partial match)')
.option('--stage <id>', 'Filter by stage ID')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching deals...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
// Resolve pipeline name to ID if provided
let pipelineId = options.pipeline;
if (options.pipelineName) {
const pipelines = await getPipelines();
const searchTerm = options.pipelineName.toLowerCase();
const matches = pipelines.filter(p =>
p.label.toLowerCase().includes(searchTerm)
);
if (matches.length === 0) {
stopSpinner(spinner);
console.error(`No pipeline found matching "${options.pipelineName}"`);
console.error('\nAvailable pipelines:');
pipelines.forEach(p => console.error(` ${p.id}: ${p.label}`));
process.exit(1);
}
if (matches.length > 1) {
stopSpinner(spinner);
console.error(`Multiple pipelines match "${options.pipelineName}":`);
matches.forEach(p => console.error(` ${p.id}: ${p.label}`));
console.error('\nUse --pipeline <id> to specify exactly.');
process.exit(1);
}
pipelineId = matches[0].id;
if (!options.json) {
// Show which pipeline was matched (stderr so it doesn't break JSON piping)
process.stderr.write(`Using pipeline: ${matches[0].label} (${pipelineId})\n`);
}
}
// Use filterDeals if pipeline or stage filter is specified
const result = (pipelineId || options.stage)
? await filterDeals({
limit: parseInt(options.limit),
after: options.after,
properties,
pipeline: pipelineId,
stage: options.stage,
})
: await getDeals({
limit: parseInt(options.limit),
after: options.after,
properties,
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatDealsMarkdown(result.results));
break;
default:
console.log(formatDeals(result.results));
}
// Only show pagination hint for non-JSON output (JSON includes paging in response)
if (result.paging?.next?.after && format !== 'json') {
console.log(`\nNext page: --after ${result.paging.next.after}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch deals');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const dealCommand = new Command('deal')
.description('Get deal by ID')
.argument('<id>', 'Deal ID')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const spinner = createSpinner('Fetching deal...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const deal = await getDeal(id, properties);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(deal));
break;
case 'markdown':
console.log(formatDealMarkdown(deal));
break;
default:
console.log(formatDeal(deal));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch deal');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const dealSearchCommand = new Command('deal-search')
.description('Search deals')
.argument('<query>', 'Search query')
.option('-n, --limit <number>', 'Maximum results', '20')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (query, options) => {
const spinner = createSpinner('Searching deals...', options);
try {
const result = await searchDeals(query, {
limit: parseInt(options.limit),
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatDealsMarkdown(result.results));
break;
default:
console.log(formatDeals(result.results));
}
} catch (error) {
failSpinner(spinner, 'Failed to search deals');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const pipelinesCommand = new Command('pipelines')
.description('List deal pipelines and stages')
.option('-s, --search <term>', 'Search pipelines by name (case-insensitive)')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching pipelines...', options);
try {
let pipelines = await getPipelines();
// Filter by search term if provided
if (options.search) {
const searchTerm = options.search.toLowerCase();
pipelines = pipelines.filter(p =>
p.label.toLowerCase().includes(searchTerm) ||
p.stages.some(s => s.label.toLowerCase().includes(searchTerm))
);
if (pipelines.length === 0) {
stopSpinner(spinner);
console.error(`No pipelines found matching "${options.search}"`);
process.exit(1);
}
}
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(pipelines));
break;
case 'markdown':
console.log(formatPipelinesMarkdown(pipelines));
break;
default:
console.log(formatPipelines(pipelines));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch pipelines');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export { authCommand, authLoginCommand, authLogoutCommand, authStatusCommand } from './auth.js';
export { checkCommand } from './check.js';
export { whoamiCommand } from './whoami.js';
export { contactsCommand, contactCommand, contactSearchCommand, contactCreateCommand, contactUpdateCommand } from './contacts.js';
export { companiesCommand, companyCommand, companySearchCommand } from './companies.js';
export { dealsCommand, dealCommand, dealSearchCommand, pipelinesCommand } from './deals.js';
export { ticketsCommand, ticketCommand, ticketSearchCommand } from './tickets.js';
export { notesCommand, noteCreateCommand } from './notes.js';
export { tasksCommand, taskCommand, taskCreateCommand } from './tasks.js';
export { associationsCommand, associateCommand } from './associations.js';
import { Command } from 'commander';
import { getNotes, createNote } from '../client.js';
import { formatJson, formatNotes, formatNotesMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner, succeedSpinner } from '../formatters/index.js';
export const notesCommand = new Command('notes')
.description('List notes for an object')
.argument('<objectType>', 'Object type (contacts, companies, deals, tickets)')
.argument('<id>', 'Object ID')
.option('-n, --limit <number>', 'Maximum number of notes', '20')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (objectType, id, options) => {
const spinner = createSpinner('Fetching notes...', options);
try {
const result = await getNotes(objectType, id, {
limit: parseInt(options.limit),
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatNotesMarkdown(result.results));
break;
default:
console.log(formatNotes(result.results));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch notes');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const noteCreateCommand = new Command('note-create')
.description('Create a note for an object')
.argument('<objectType>', 'Object type (contacts, companies, deals, tickets)')
.argument('<id>', 'Object ID')
.argument('<body>', 'Note body')
.option('--json', 'Output as JSON')
.action(async (objectType, id, body, options) => {
const spinner = createSpinner('Creating note...', options);
try {
const note = await createNote(objectType, id, body);
succeedSpinner(spinner, 'Note created!');
if (options.json) {
console.log(formatJson(note));
} else {
console.log(`Note ID: ${note.id}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to create note');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import { getTasks, getTask, createTask } from '../client.js';
import { formatJson, formatTasks, formatTask, formatTasksMarkdown, formatTaskMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner, succeedSpinner } from '../formatters/index.js';
export const tasksCommand = new Command('tasks')
.description('List tasks')
.option('-n, --limit <number>', 'Maximum number of tasks', '20')
.option('--after <cursor>', 'Pagination cursor')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching tasks...', options);
try {
const result = await getTasks({
limit: parseInt(options.limit),
after: options.after,
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatTasksMarkdown(result.results));
break;
default:
console.log(formatTasks(result.results));
}
// Only show pagination hint for non-JSON output (JSON includes paging in response)
if (result.paging?.next?.after && format !== 'json') {
console.log(`\nNext page: --after ${result.paging.next.after}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch tasks');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const taskCommand = new Command('task')
.description('Get task by ID')
.argument('<id>', 'Task ID')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const spinner = createSpinner('Fetching task...', options);
try {
const task = await getTask(id);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(task));
break;
case 'markdown':
console.log(formatTaskMarkdown(task));
break;
default:
console.log(formatTask(task));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch task');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const taskCreateCommand = new Command('task-create')
.description('Create a new task')
.option('--subject <subject>', 'Task subject (required)')
.option('--body <body>', 'Task body')
.option('--due <date>', 'Due date (ISO format)')
.option('--priority <priority>', 'Priority (LOW, MEDIUM, HIGH)')
.option('--status <status>', 'Status (NOT_STARTED, IN_PROGRESS, COMPLETED)')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
if (!options.subject) {
console.error('--subject is required');
process.exit(1);
}
const spinner = createSpinner('Creating task...', options);
try {
const task = await createTask({
subject: options.subject,
body: options.body,
dueDate: options.due,
priority: options.priority?.toUpperCase() as 'LOW' | 'MEDIUM' | 'HIGH' | undefined,
status: options.status?.toUpperCase() as 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | undefined,
});
succeedSpinner(spinner, 'Task created!');
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(task));
break;
case 'markdown':
console.log(formatTaskMarkdown(task));
break;
default:
console.log(formatTask(task));
}
} catch (error) {
failSpinner(spinner, 'Failed to create task');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import { getTickets, getTicket, searchTickets } from '../client.js';
import { formatJson, formatTickets, formatTicket, formatTicketsMarkdown, formatTicketMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner } from '../formatters/index.js';
export const ticketsCommand = new Command('tickets')
.description('List tickets')
.option('-n, --limit <number>', 'Maximum number of tickets', '20')
.option('--after <cursor>', 'Pagination cursor')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching tickets...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const result = await getTickets({
limit: parseInt(options.limit),
after: options.after,
properties,
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatTicketsMarkdown(result.results));
break;
default:
console.log(formatTickets(result.results));
}
// Only show pagination hint for non-JSON output (JSON includes paging in response)
if (result.paging?.next?.after && format !== 'json') {
console.log(`\nNext page: --after ${result.paging.next.after}`);
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch tickets');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const ticketCommand = new Command('ticket')
.description('Get ticket by ID')
.argument('<id>', 'Ticket ID')
.option('--properties <props>', 'Comma-separated properties to fetch')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (id, options) => {
const spinner = createSpinner('Fetching ticket...', options);
try {
const properties = options.properties?.split(',').map((p: string) => p.trim());
const ticket = await getTicket(id, properties);
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(ticket));
break;
case 'markdown':
console.log(formatTicketMarkdown(ticket));
break;
default:
console.log(formatTicket(ticket));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch ticket');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
export const ticketSearchCommand = new Command('ticket-search')
.description('Search tickets')
.argument('<query>', 'Search query')
.option('-n, --limit <number>', 'Maximum results', '20')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (query, options) => {
const spinner = createSpinner('Searching tickets...', options);
try {
const result = await searchTickets(query, {
limit: parseInt(options.limit),
});
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(result));
break;
case 'markdown':
console.log(formatTicketsMarkdown(result.results));
break;
default:
console.log(formatTickets(result.results));
}
} catch (error) {
failSpinner(spinner, 'Failed to search tickets');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { Command } from 'commander';
import { getPortalInfo } from '../client.js';
import { formatJson, formatPortalInfo, formatPortalInfoMarkdown, getOutputFormat, createSpinner, stopSpinner, failSpinner } from '../formatters/index.js';
export const whoamiCommand = new Command('whoami')
.description('Show current HubSpot portal info')
.option('--json', 'Output as JSON')
.option('--markdown', 'Output as Markdown')
.action(async (options) => {
const spinner = createSpinner('Fetching portal info...', options);
try {
const info = await getPortalInfo();
stopSpinner(spinner);
const format = getOutputFormat(options);
switch (format) {
case 'json':
console.log(formatJson(info));
break;
case 'markdown':
console.log(formatPortalInfoMarkdown(info));
break;
default:
console.log(formatPortalInfo(info));
}
} catch (error) {
failSpinner(spinner, 'Failed to fetch portal info');
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
});
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import JSON5 from 'json5';
import type { AuthMethod, OAuthCredentials, OAuthAppConfig } from './types.js';
import { TOKEN_REFRESH_BUFFER_MS } from './oauth/constants.js';
export interface HsConfig {
accessToken?: string;
portalId?: string;
defaultFormat?: 'plain' | 'json' | 'markdown';
defaultLimit?: number;
// OAuth fields
authMethod?: AuthMethod;
oauth?: OAuthCredentials;
oauthApp?: OAuthAppConfig;
}
const DEFAULT_CONFIG: HsConfig = {
defaultFormat: 'plain',
defaultLimit: 20,
};
// Cache loaded config to avoid repeated file I/O
let cachedConfig: HsConfig | null = null;
let cachedConfigTime: number = 0;
const CONFIG_CACHE_TTL_MS = 1000; // 1 second cache
function getGlobalConfigPath(): string {
return join(homedir(), '.config', 'hs', 'config.json5');
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readConfigFile(path: string, warn: (message: string) => void): Partial<HsConfig> {
if (!existsSync(path)) {
return {};
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
// Validate that parsed result is a plain object
if (!isPlainObject(parsed)) {
warn(`Config at ${path} must be an object, got ${typeof parsed}`);
return {};
}
return parsed as Partial<HsConfig>;
} catch (error) {
warn(`Failed to parse config at ${path}: ${error instanceof Error ? error.message : String(error)}`);
return {};
}
}
export function loadConfig(warn: (message: string) => void = console.warn): HsConfig {
// Return cached config if still valid
const now = Date.now();
if (cachedConfig && (now - cachedConfigTime) < CONFIG_CACHE_TTL_MS) {
return cachedConfig;
}
const globalPath = getGlobalConfigPath();
// Only load from global config - local config disabled for security
cachedConfig = {
...DEFAULT_CONFIG,
...readConfigFile(globalPath, warn),
};
cachedConfigTime = now;
return cachedConfig;
}
export function saveConfig(config: Partial<HsConfig>): void {
const path = getGlobalConfigPath();
const dir = dirname(path);
// Create directory with restrictive permissions (owner only)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
// Load existing config and merge, with proper error handling
let existing: Partial<HsConfig> = {};
if (existsSync(path)) {
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
if (isPlainObject(parsed)) {
existing = parsed as Partial<HsConfig>;
}
} catch (error) {
// Log but don't fail - we'll overwrite with new config
console.warn(`Warning: Could not read existing config, will be overwritten: ${error instanceof Error ? error.message : String(error)}`);
}
}
const merged = { ...existing, ...config };
const content = JSON5.stringify(merged, null, 2);
// Write with restrictive permissions (owner read/write only)
writeFileSync(path, content, { encoding: 'utf8', mode: 0o600 });
// Invalidate cache
cachedConfig = null;
}
export function getConfigPath(): string {
return getGlobalConfigPath();
}
export function isConfigured(): boolean {
const config = loadConfig(() => {});
return (config.accessToken ?? '') !== '';
}
export function setAccessToken(token: string): void {
saveConfig({ accessToken: token });
}
export function getAccessToken(): string | undefined {
const config = loadConfig(() => {});
return config.accessToken;
}
export function setPortalId(portalId: string): void {
saveConfig({ portalId });
}
export function getPortalId(): string | undefined {
const config = loadConfig(() => {});
return config.portalId;
}
export function getDefaultLimit(): number {
const config = loadConfig(() => {});
return config.defaultLimit ?? 20;
}
// OAuth Configuration Functions
export function getAuthMethod(): AuthMethod {
const config = loadConfig(() => {});
return config.authMethod ?? 'token';
}
export function setAuthMethod(method: AuthMethod): void {
saveConfig({ authMethod: method });
}
export function getOAuthCredentials(): OAuthCredentials | undefined {
const config = loadConfig(() => {});
return config.oauth;
}
export function setOAuthCredentials(credentials: OAuthCredentials): void {
saveConfig({ oauth: credentials, authMethod: 'oauth' });
}
export function clearOAuthCredentials(): void {
const path = getGlobalConfigPath();
if (!existsSync(path)) {
return;
}
try {
const raw = readFileSync(path, 'utf8');
const parsed = JSON5.parse(raw);
if (isPlainObject(parsed)) {
delete parsed.oauth;
delete parsed.authMethod;
delete parsed.oauthApp;
const content = JSON5.stringify(parsed, null, 2);
writeFileSync(path, content, { encoding: 'utf8', mode: 0o600 });
// Invalidate cache
cachedConfig = null;
}
} catch {
// Ignore errors - config will be recreated on next save
}
}
export function getOAuthAppConfig(): OAuthAppConfig | undefined {
const config = loadConfig(() => {});
return config.oauthApp;
}
export function setOAuthAppConfig(appConfig: OAuthAppConfig): void {
saveConfig({ oauthApp: appConfig });
}
export function isTokenExpired(): boolean {
const credentials = getOAuthCredentials();
if (!credentials) {
return true;
}
// Consider expired if within the refresh buffer
return Date.now() >= (credentials.expiresAt - TOKEN_REFRESH_BUFFER_MS);
}
export function getTimeUntilExpiry(): number {
const credentials = getOAuthCredentials();
if (!credentials) {
return 0;
}
return Math.max(0, credentials.expiresAt - Date.now());
}
export function isOAuthConfigured(): boolean {
const config = loadConfig(() => {});
return config.authMethod === 'oauth' &&
config.oauth?.accessToken !== undefined &&
config.oauth?.refreshToken !== undefined;
}
import ora, { type Ora } from 'ora';
export * from './json.js';
export * from './plain.js';
export * from './markdown.js';
export type OutputFormat = 'plain' | 'json' | 'markdown';
export function getOutputFormat(options: { json?: boolean; markdown?: boolean; plain?: boolean }): OutputFormat {
if (options.json) return 'json';
if (options.markdown) return 'markdown';
return 'plain';
}
// Spinner that only runs for non-JSON output (prevents stdout corruption when piping)
export function createSpinner(text: string, options: { json?: boolean }): Ora | null {
if (options.json) return null;
return ora(text).start();
}
export function stopSpinner(spinner: Ora | null): void {
spinner?.stop();
}
export function failSpinner(spinner: Ora | null, text: string): void {
spinner?.fail(text);
}
export function succeedSpinner(spinner: Ora | null, text: string): void {
spinner?.succeed(text);
}
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
import type { Contact, Company, Deal, Ticket, Note, Task, Association, Pipeline, PortalInfo } from '../types.js';
export function formatPortalInfoMarkdown(info: PortalInfo): string {
const lines: string[] = ['# HubSpot Portal\n'];
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| Portal ID | ${info.portalId} |`);
lines.push(`| Timezone | ${info.timeZone} |`);
lines.push(`| Currency | ${info.currency} |`);
if (info.domain) lines.push(`| Domain | ${info.domain} |`);
if (info.companyName) lines.push(`| Company | ${info.companyName} |`);
return lines.join('\n');
}
export function formatContactsMarkdown(contacts: Contact[]): string {
if (contacts.length === 0) {
return 'No contacts found';
}
const lines: string[] = ['# Contacts\n'];
lines.push('| Name | Email | Company | Job Title | ID |');
lines.push('|------|-------|---------|-----------|-------|');
for (const contact of contacts) {
const name = [contact.firstname, contact.lastname].filter(Boolean).join(' ') || '-';
const email = contact.email || '-';
const company = contact.company || '-';
const jobtitle = contact.jobtitle || '-';
lines.push(`| ${name} | ${email} | ${company} | ${jobtitle} | ${contact.id} |`);
}
return lines.join('\n');
}
export function formatContactMarkdown(contact: Contact): string {
const lines: string[] = [];
const name = [contact.firstname, contact.lastname].filter(Boolean).join(' ') || 'Unknown';
lines.push(`# ${name}\n`);
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| ID | ${contact.id} |`);
if (contact.email) lines.push(`| Email | ${contact.email} |`);
if (contact.phone) lines.push(`| Phone | ${contact.phone} |`);
if (contact.company) lines.push(`| Company | ${contact.company} |`);
if (contact.jobtitle) lines.push(`| Job Title | ${contact.jobtitle} |`);
if (contact.lifecyclestage) lines.push(`| Lifecycle Stage | ${contact.lifecyclestage} |`);
if (contact.hs_lead_status) lines.push(`| Lead Status | ${contact.hs_lead_status} |`);
if (contact.createdate) lines.push(`| Created | ${contact.createdate} |`);
return lines.join('\n');
}
export function formatCompaniesMarkdown(companies: Company[]): string {
if (companies.length === 0) {
return 'No companies found';
}
const lines: string[] = ['# Companies\n'];
lines.push('| Name | Domain | Industry | Location | ID |');
lines.push('|------|--------|----------|----------|-------|');
for (const company of companies) {
const name = company.name || '-';
const domain = company.domain || '-';
const industry = company.industry || '-';
const location = [company.city, company.state, company.country].filter(Boolean).join(', ') || '-';
lines.push(`| ${name} | ${domain} | ${industry} | ${location} | ${company.id} |`);
}
return lines.join('\n');
}
export function formatCompanyMarkdown(company: Company): string {
const lines: string[] = [];
const name = company.name || 'Unknown';
lines.push(`# ${name}\n`);
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| ID | ${company.id} |`);
if (company.domain) lines.push(`| Domain | ${company.domain} |`);
if (company.industry) lines.push(`| Industry | ${company.industry} |`);
if (company.phone) lines.push(`| Phone | ${company.phone} |`);
if (company.city || company.state || company.country) {
const location = [company.city, company.state, company.country].filter(Boolean).join(', ');
lines.push(`| Location | ${location} |`);
}
if (company.numberofemployees) lines.push(`| Employees | ${company.numberofemployees} |`);
if (company.annualrevenue) lines.push(`| Annual Revenue | ${company.annualrevenue} |`);
if (company.createdate) lines.push(`| Created | ${company.createdate} |`);
if (company.description) {
lines.push(`\n## Description\n${company.description}`);
}
return lines.join('\n');
}
export function formatDealsMarkdown(deals: Deal[]): string {
if (deals.length === 0) {
return 'No deals found';
}
const lines: string[] = ['# Deals\n'];
lines.push('| Deal Name | Amount | Stage | Close Date | ID |');
lines.push('|-----------|--------|-------|------------|-------|');
for (const deal of deals) {
const name = deal.dealname || '-';
const amount = deal.amount ? `$${deal.amount}` : '-';
const stage = deal.dealstage || '-';
const closedate = deal.closedate || '-';
lines.push(`| ${name} | ${amount} | ${stage} | ${closedate} | ${deal.id} |`);
}
return lines.join('\n');
}
export function formatDealMarkdown(deal: Deal): string {
const lines: string[] = [];
const name = deal.dealname || 'Unknown';
lines.push(`# ${name}\n`);
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| ID | ${deal.id} |`);
if (deal.amount) lines.push(`| Amount | $${deal.amount} |`);
if (deal.dealstage) lines.push(`| Stage | ${deal.dealstage} |`);
if (deal.pipeline) lines.push(`| Pipeline | ${deal.pipeline} |`);
if (deal.closedate) lines.push(`| Close Date | ${deal.closedate} |`);
if (deal.hs_deal_stage_probability) lines.push(`| Probability | ${deal.hs_deal_stage_probability}% |`);
if (deal.dealtype) lines.push(`| Type | ${deal.dealtype} |`);
if (deal.createdate) lines.push(`| Created | ${deal.createdate} |`);
if (deal.description) {
lines.push(`\n## Description\n${deal.description}`);
}
return lines.join('\n');
}
export function formatTicketsMarkdown(tickets: Ticket[]): string {
if (tickets.length === 0) {
return 'No tickets found';
}
const lines: string[] = ['# Tickets\n'];
lines.push('| Subject | Priority | Stage | Created | ID |');
lines.push('|---------|----------|-------|---------|-------|');
for (const ticket of tickets) {
const subject = ticket.subject || '-';
const priority = ticket.hs_ticket_priority || '-';
const stage = ticket.hs_pipeline_stage || '-';
const createdate = ticket.createdate || '-';
lines.push(`| ${subject} | ${priority} | ${stage} | ${createdate} | ${ticket.id} |`);
}
return lines.join('\n');
}
export function formatTicketMarkdown(ticket: Ticket): string {
const lines: string[] = [];
const subject = ticket.subject || 'Unknown';
lines.push(`# ${subject}\n`);
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| ID | ${ticket.id} |`);
if (ticket.hs_ticket_priority) lines.push(`| Priority | ${ticket.hs_ticket_priority} |`);
if (ticket.hs_pipeline_stage) lines.push(`| Stage | ${ticket.hs_pipeline_stage} |`);
if (ticket.hs_ticket_category) lines.push(`| Category | ${ticket.hs_ticket_category} |`);
if (ticket.createdate) lines.push(`| Created | ${ticket.createdate} |`);
if (ticket.content) {
lines.push(`\n## Content\n${ticket.content}`);
}
return lines.join('\n');
}
export function formatNotesMarkdown(notes: Note[]): string {
if (notes.length === 0) {
return 'No notes found';
}
const lines: string[] = ['# Notes\n'];
for (const note of notes) {
const date = note.hs_timestamp || 'Unknown date';
lines.push(`## Note ${note.id} - ${date}\n`);
lines.push(note.hs_note_body || '*(empty)*');
lines.push('');
}
return lines.join('\n');
}
export function formatTasksMarkdown(tasks: Task[]): string {
if (tasks.length === 0) {
return 'No tasks found';
}
const lines: string[] = ['# Tasks\n'];
lines.push('| Subject | Status | Priority | Due Date | ID |');
lines.push('|---------|--------|----------|----------|-------|');
for (const task of tasks) {
const subject = task.hs_task_subject || '-';
const status = task.hs_task_status || '-';
const priority = task.hs_task_priority || '-';
const dueDate = task.hs_timestamp || '-';
lines.push(`| ${subject} | ${status} | ${priority} | ${dueDate} | ${task.id} |`);
}
return lines.join('\n');
}
export function formatTaskMarkdown(task: Task): string {
const lines: string[] = [];
const subject = task.hs_task_subject || 'Unknown';
lines.push(`# ${subject}\n`);
lines.push('| Field | Value |');
lines.push('|-------|-------|');
lines.push(`| ID | ${task.id} |`);
if (task.hs_task_status) lines.push(`| Status | ${task.hs_task_status} |`);
if (task.hs_task_priority) lines.push(`| Priority | ${task.hs_task_priority} |`);
if (task.hs_task_type) lines.push(`| Type | ${task.hs_task_type} |`);
if (task.hs_timestamp) lines.push(`| Due Date | ${task.hs_timestamp} |`);
if (task.hs_task_body) {
lines.push(`\n## Body\n${task.hs_task_body}`);
}
return lines.join('\n');
}
export function formatAssociationsMarkdown(associations: Association[]): string {
if (associations.length === 0) {
return 'No associations found';
}
const lines: string[] = ['# Associations\n'];
lines.push('| Type | Object ID | Association |');
lines.push('|------|-----------|-------------|');
for (const assoc of associations) {
const label = assoc.associationTypes[0]?.label || 'Associated';
lines.push(`| ${assoc.toObjectType} | ${assoc.toObjectId} | ${label} |`);
}
return lines.join('\n');
}
export function formatPipelinesMarkdown(pipelines: Pipeline[]): string {
const lines: string[] = ['# Deal Pipelines\n'];
for (const pipeline of pipelines) {
lines.push(`## ${pipeline.label}\n`);
lines.push('| Stage | Order | Probability |');
lines.push('|-------|-------|-------------|');
for (const stage of pipeline.stages.sort((a, b) => a.displayOrder - b.displayOrder)) {
const probability = stage.metadata.probability || '-';
lines.push(`| ${stage.label} | ${stage.displayOrder} | ${probability}% |`);
}
lines.push('');
}
return lines.join('\n');
}
#!/usr/bin/env node
import { Command } from 'commander';
import {
authCommand,
checkCommand,
whoamiCommand,
contactsCommand,
contactCommand,
contactSearchCommand,
contactCreateCommand,
contactUpdateCommand,
companiesCommand,
companyCommand,
companySearchCommand,
dealsCommand,
dealCommand,
dealSearchCommand,
pipelinesCommand,
ticketsCommand,
ticketCommand,
ticketSearchCommand,
notesCommand,
noteCreateCommand,
tasksCommand,
taskCommand,
taskCreateCommand,
associationsCommand,
associateCommand,
} from './commands/index.js';
const program = new Command();
program
.name('hs')
.description('HubSpot CRM CLI for managing contacts, companies, deals, and engagements')
.version('0.1.0');
// Auth commands
program.addCommand(authCommand);
program.addCommand(checkCommand);
program.addCommand(whoamiCommand);
// Contact commands
program.addCommand(contactsCommand);
program.addCommand(contactCommand);
program.addCommand(contactSearchCommand);
program.addCommand(contactCreateCommand);
program.addCommand(contactUpdateCommand);
// Company commands
program.addCommand(companiesCommand);
program.addCommand(companyCommand);
program.addCommand(companySearchCommand);
// Deal commands
program.addCommand(dealsCommand);
program.addCommand(dealCommand);
program.addCommand(dealSearchCommand);
program.addCommand(pipelinesCommand);
// Ticket commands
program.addCommand(ticketsCommand);
program.addCommand(ticketCommand);
program.addCommand(ticketSearchCommand);
// Engagement commands
program.addCommand(notesCommand);
program.addCommand(noteCreateCommand);
program.addCommand(tasksCommand);
program.addCommand(taskCommand);
program.addCommand(taskCreateCommand);
// Association commands
program.addCommand(associationsCommand);
program.addCommand(associateCommand);
program.parse();
// Cross-platform browser opening
import { spawn } from 'node:child_process';
import { platform } from 'node:os';
/**
* Opens a URL in the user's default browser.
* Works cross-platform: macOS, Windows, and Linux.
*/
export function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const os = platform();
let command: string;
let args: string[];
switch (os) {
case 'darwin':
command = 'open';
args = [url];
break;
case 'win32':
command = 'cmd';
args = ['/c', 'start', '', url];
break;
default:
// Linux and other Unix-like systems
command = 'xdg-open';
args = [url];
break;
}
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
});
child.on('error', (error) => {
reject(new Error(`Failed to open browser: ${error.message}`));
});
child.unref();
// Give the browser a moment to start
setTimeout(resolve, 500);
});
}
// Local HTTP server to receive OAuth callback
import { createServer, IncomingMessage, ServerResponse, Server } from 'node:http';
import { URL } from 'node:url';
import { CALLBACK_PORT, CALLBACK_PATH, CALLBACK_TIMEOUT_MS } from './constants.js';
export interface CallbackResult {
code: string;
state: string;
}
const SUCCESS_HTML = `<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(10px);
}
h1 { margin-bottom: 0.5rem; }
p { opacity: 0.9; }
.checkmark {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✓</div>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the CLI.</p>
</div>
</body>
</html>`;
const ERROR_HTML = (message: string) => `<!DOCTYPE html>
<html>
<head>
<title>Authorization Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
color: white;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
backdrop-filter: blur(10px);
}
h1 { margin-bottom: 0.5rem; }
p { opacity: 0.9; }
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">✗</div>
<h1>Authorization Failed</h1>
<p>${message}</p>
<p>Please try again from the CLI.</p>
</div>
</body>
</html>`;
/**
* Starts a local HTTP server to receive the OAuth callback.
* Validates the state parameter for CSRF protection.
* Returns when the callback is received or times out.
*/
export function startCallbackServer(expectedState: string): Promise<CallbackResult> {
return new Promise((resolve, reject) => {
let server: Server | null = null;
let timeoutId: NodeJS.Timeout | null = null;
const cleanup = () => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (server) {
server.close();
server = null;
}
};
server = createServer((req: IncomingMessage, res: ServerResponse) => {
// Only handle the callback path
if (!req.url?.startsWith(CALLBACK_PATH)) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
try {
const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
const errorDescription = url.searchParams.get('error_description');
// Handle error response from HubSpot
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(ERROR_HTML(errorDescription || error));
cleanup();
reject(new Error(`OAuth error: ${errorDescription || error}`));
return;
}
// Validate required parameters
if (!code || !state) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(ERROR_HTML('Missing authorization code or state parameter.'));
cleanup();
reject(new Error('Missing authorization code or state parameter'));
return;
}
// Validate state for CSRF protection
if (state !== expectedState) {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(ERROR_HTML('State parameter mismatch. Possible CSRF attack.'));
cleanup();
reject(new Error('State parameter mismatch - possible CSRF attack'));
return;
}
// Success!
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(SUCCESS_HTML);
cleanup();
resolve({ code, state });
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/html' });
res.end(ERROR_HTML('Internal server error'));
cleanup();
reject(err);
}
});
server.on('error', (err: NodeJS.ErrnoException) => {
cleanup();
if (err.code === 'EADDRINUSE') {
reject(new Error(`Port ${CALLBACK_PORT} is already in use. Close any other HubSpot CLI instances and try again.`));
} else {
reject(new Error(`Failed to start callback server: ${err.message}`));
}
});
// Set timeout for user to complete authorization
timeoutId = setTimeout(() => {
cleanup();
reject(new Error(`Authorization timed out after ${CALLBACK_TIMEOUT_MS / 1000} seconds. Please try again.`));
}, CALLBACK_TIMEOUT_MS);
server.listen(CALLBACK_PORT, '127.0.0.1', () => {
// Server is ready, callback URL can now be used
});
});
}
// OAuth Configuration Constants
export const HUBSPOT_AUTH_URL = 'https://app.hubspot.com/oauth/authorize';
export const HUBSPOT_TOKEN_URL = 'https://api.hubapi.com/oauth/v1/token';
// Local callback server configuration
export const CALLBACK_PORT = 3847;
export const CALLBACK_PATH = '/callback';
export const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
// Token refresh buffer (5 minutes before expiry)
export const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000;
// Callback server timeout (2 minutes for user to complete authorization)
export const CALLBACK_TIMEOUT_MS = 2 * 60 * 1000;
// Default OAuth scopes for CLI functionality
export const DEFAULT_SCOPES = [
'crm.objects.contacts.read',
'crm.objects.contacts.write',
'crm.objects.companies.read',
'crm.objects.companies.write',
'crm.objects.deals.read',
'crm.objects.deals.write',
'crm.objects.owners.read',
'crm.schemas.contacts.read',
'crm.schemas.contacts.write',
'crm.schemas.companies.read',
'crm.schemas.deals.read',
'oauth',
'tickets',
'account-info.security.read',
];