From 1bb70a1f27e382f0388f5b3a7e5f1b4d0b2aa035 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 16:13:03 +0200 Subject: [PATCH 1/9] feat(docs): Docs API read foundation + `docs collection list` Adds the Help Scout Docs API surface (separate product, separate per-user key): - DocsBaseCommand: skips Mailbox OAuth, builds a Docs client from the resolved key. - docs-client: HTTP Basic auth (key:X), host-locked to docsapi.helpscout.net, Docs envelope pagination, 429 + 5xx backoff. - docs-auth: resolveDocsKey (flag > HSCLI_DOCS_API_KEY env > keychain) + keychain docs-key storage. - hscli docs collection list (--site/--visibility/--limit + --output/--jq/--fields). - 100% coverage; live-verified read-only against a prod Docs account. --- package.json | 6 + src/commands/docs/collection/list.js | 45 ++++++ src/docs-base-command.js | 38 +++++ src/lib/docs-auth.js | 37 +++++ src/lib/docs-client.js | 134 ++++++++++++++++++ src/lib/keychain.js | 30 ++++ test/commands/docs/collection/list.test.js | 101 ++++++++++++++ test/fixtures/docs-collections-list.json | 43 ++++++ test/lib/docs-auth.test.js | 50 +++++++ test/lib/docs-client.test.js | 154 +++++++++++++++++++++ test/lib/keychain-error.test.js | 26 ++++ test/lib/keychain-fallback.test.js | 23 ++- test/lib/keychain.test.js | 21 +++ 13 files changed, 706 insertions(+), 2 deletions(-) create mode 100644 src/commands/docs/collection/list.js create mode 100644 src/docs-base-command.js create mode 100644 src/lib/docs-auth.js create mode 100644 src/lib/docs-client.js create mode 100644 test/commands/docs/collection/list.test.js create mode 100644 test/fixtures/docs-collections-list.json create mode 100644 test/lib/docs-auth.test.js create mode 100644 test/lib/docs-client.test.js create mode 100644 test/lib/keychain-error.test.js diff --git a/package.json b/package.json index 2ad5d37..810a974 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,12 @@ }, "beacon": { "description": "Beacon widget utilities (HMAC signing, snippet generators)" + }, + "docs": { + "description": "Help Scout Docs — knowledge base sites, collections, categories, articles" + }, + "docs:collection": { + "description": "Docs collections" } }, "hooks": { diff --git a/src/commands/docs/collection/list.js b/src/commands/docs/collection/list.js new file mode 100644 index 0000000..5cfc4fc --- /dev/null +++ b/src/commands/docs/collection/list.js @@ -0,0 +1,45 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { collectPages } from '../../../lib/pagination.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + visibility: { header: 'Visibility' }, + articleCount: { header: 'Articles' }, + updatedAt: { header: 'Updated' }, +} + +export default class DocsCollectionListCommand extends DocsBaseCommand { + static description = 'List Docs collections' + + static examples = [ + '<%= config.bin %> docs collection list', + '<%= config.bin %> docs collection list --site ', + '<%= config.bin %> docs collection list --visibility public --output json', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + limit: Flags.integer({ description: 'Max results to return', default: 50 }), + site: Flags.string({ description: 'Filter by Site id' }), + visibility: Flags.string({ + description: 'Filter by visibility', + options: ['all', 'public', 'private'], + }), + } + + async run() { + const { flags } = await this.parse(DocsCollectionListCommand) + const items = await collectPages( + this.docsClient.paginate( + 'collections', + { siteId: flags.site, visibility: flags.visibility }, + 'collections', + ), + flags.limit, + ) + await this.outputResults(items, columns) + } +} diff --git a/src/docs-base-command.js b/src/docs-base-command.js new file mode 100644 index 0000000..047aff8 --- /dev/null +++ b/src/docs-base-command.js @@ -0,0 +1,38 @@ +import { Flags } from '@oclif/core' +import BaseCommand from './base-command.js' +import { resolveDocsKey } from './lib/docs-auth.js' +import { createDocsClient } from './lib/docs-client.js' + +/** + * Base for `hscli docs …` commands. The Docs API is a separate product with + * its own per-user API key, so these commands skip the Mailbox OAuth flow + * (skipAuth) and build a Docs client from the resolved Docs key instead. + */ +export default class DocsBaseCommand extends BaseCommand { + static skipAuth = true + + static baseFlags = { + ...BaseCommand.baseFlags, + 'api-key': Flags.string({ + description: 'Docs API key (overrides env/keychain)', + helpGroup: 'GLOBAL', + }), + } + + /** @type {import('./lib/docs-client.js').createDocsClient} */ + docsClient + + async init() { + await super.init() // loads config + flags; skips Mailbox auth via skipAuth + const { apiKey } = await resolveDocsKey({ + flags: { apiKey: this.flags['api-key'] }, + profile: this.activeProfile, + }) + this.docsClient = createDocsClient({ + apiKey, + retry: !this.flags['no-retry'], + timeout: this.flags.timeout, + userAgent: `hscli/${this.config.version}`, + }) + } +} diff --git a/src/lib/docs-auth.js b/src/lib/docs-auth.js new file mode 100644 index 0000000..bef596c --- /dev/null +++ b/src/lib/docs-auth.js @@ -0,0 +1,37 @@ +import { getDocsKey } from './keychain.js' +import { ConfigError } from './errors.js' + +/** + * @typedef {object} ResolvedDocsKey + * @property {string} apiKey + * @property {'flags' | 'env' | 'keychain'} source + */ + +/** + * Resolve the Help Scout Docs API key. + * Precedence (highest first): flag → env (HSCLI_DOCS_API_KEY) → keychain. + * The Docs API is a separate product with its own per-user key (used as the + * HTTP Basic-auth username), independent of the Mailbox OAuth credentials. + * + * @param {object} [options] + * @param {object} [options.flags] + * @param {string} [options.flags.apiKey] + * @param {string} [options.profile] + * @returns {Promise} + */ +export async function resolveDocsKey({ flags, profile } = {}) { + if (flags?.apiKey) return { apiKey: flags.apiKey, source: 'flags' } + + if (process.env.HSCLI_DOCS_API_KEY) { + return { apiKey: process.env.HSCLI_DOCS_API_KEY, source: 'env' } + } + + if (profile) { + const key = await getDocsKey(profile) + if (key) return { apiKey: key, source: 'keychain' } + } + + throw new ConfigError( + 'No Docs API key found. Set HSCLI_DOCS_API_KEY or run: hscli docs auth', + ) +} diff --git a/src/lib/docs-client.js b/src/lib/docs-client.js new file mode 100644 index 0000000..ff293ac --- /dev/null +++ b/src/lib/docs-client.js @@ -0,0 +1,134 @@ +import createDebug from 'debug' +import { + ApiError, + CliError, + RateLimitError, + ServiceUnavailableError, +} from './errors.js' + +const debug = createDebug('hs:docs-client') +const BASE_URL = 'https://docsapi.helpscout.net/v1/' +const BASE_ORIGIN = new URL(BASE_URL).origin + +function jitter() { + return Math.floor(Math.random() * 1000) +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * Help Scout Docs API client. Authenticates with the per-user Docs API key via + * HTTP Basic auth (key as username, dummy "X" password) and is host-locked to + * docsapi.helpscout.net. + * + * @param {object} options + * @param {string} options.apiKey + * @param {number} [options.timeout] + * @param {boolean} [options.retry] + * @param {string} [options.userAgent] + */ +export function createDocsClient({ + apiKey, + timeout = 30_000, + retry = true, + userAgent = 'hscli', +}) { + const authorization = `Basic ${Buffer.from(`${apiKey}:X`).toString('base64')}` + + async function request(method, path, { body, query } = {}) { + const url = new URL(String(path).replace(/^\//, ''), BASE_URL) + if (url.origin !== BASE_ORIGIN) { + throw new CliError( + `Refusing to send request to non-Help Scout Docs host: ${url.origin}`, + { exitCode: 78 }, + ) + } + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v == null) continue + url.searchParams.set(k, String(v)) + } + } + + const maxAttempts = retry ? 3 : 1 + let attempts = 0 + + while (attempts < maxAttempts) { + attempts++ + + const res = await fetch(url, { + method, + headers: { + authorization, + accept: 'application/json', + 'content-type': 'application/json', + 'user-agent': userAgent, + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(timeout), + }) + + debug('%s %s → %d', method, path, res.status) + + if (res.status === 429) { + const wait = Number( + res.headers.get('x-ratelimit-reset') || + res.headers.get('retry-after') || + 10, + ) + if (!retry) throw new RateLimitError(wait) + debug('rate limited, waiting %ds', wait) + await sleep(wait * 1000) + continue + } + + if (res.status >= 500 && attempts < maxAttempts) { + const delay = Math.min(1000 * 2 ** attempts, 30_000) + jitter() + debug('server error %d, retrying in %dms', res.status, delay) + await sleep(delay) + continue + } + + if (res.status === 204) return null + + const text = await res.text() + if (!res.ok) throw ApiError.fromResponse(res.status, text, path) + return text ? JSON.parse(text) : null + } + + throw new ServiceUnavailableError() + } + + /** + * Page through a Docs list endpoint. The Docs envelope is + * `{ : { page, pages, count, items: [...] } }`. + * @param {string} path + * @param {object} [query] + * @param {string} resourceKey + * @param {{ onProgress?: (info: {page: number, totalPages: number}) => void }} [opts] + * @returns {AsyncGenerator} + */ + async function* paginate(path, query = {}, resourceKey, opts = {}) { + let page = 1 + while (true) { + const data = await request('GET', path, { query: { ...query, page } }) + const wrap = data?.[resourceKey] ?? {} + const items = wrap.items ?? [] + const totalPages = wrap.pages ?? 1 + if (opts.onProgress) opts.onProgress({ page, totalPages }) + yield* items + if (page >= totalPages) break + page++ + } + } + + return { + get: (path, opts) => request('GET', path, opts), + post: (path, opts) => request('POST', path, opts), + put: (path, opts) => request('PUT', path, opts), + del: (path, opts) => request('DELETE', path, opts), + paginate, + } +} diff --git a/src/lib/keychain.js b/src/lib/keychain.js index 29a92ea..01f08b0 100644 --- a/src/lib/keychain.js +++ b/src/lib/keychain.js @@ -70,6 +70,36 @@ export async function deleteTokens(profile) { getEntry(account).deletePassword() } +/** + * Help Scout Docs API key (separate product, separate per-user key). + * @param {string} profile + * @returns {Promise} + */ +export async function getDocsKey(profile) { + if (!Entry) return null + try { + return getEntry(`${profile}/docs-key`).getPassword() || null + } catch (err) { + debug('getDocsKey error: %s', err.message) + return null + } +} + +/** + * @param {string} profile + * @param {string} apiKey + */ +export async function setDocsKey(profile, apiKey) { + if (!Entry) keychainRequired() + getEntry(`${profile}/docs-key`).setPassword(apiKey) +} + +/** @param {string} profile */ +export async function deleteDocsKey(profile) { + if (!Entry) return + getEntry(`${profile}/docs-key`).deletePassword() +} + export function isKeychainAvailable() { return Entry !== null } diff --git a/test/commands/docs/collection/list.test.js b/test/commands/docs/collection/list.test.js new file mode 100644 index 0000000..f0abe5d --- /dev/null +++ b/test/commands/docs/collection/list.test.js @@ -0,0 +1,101 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-collections-list.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) + +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi + .fn() + .mockReturnValue({ apiKey: 'test-docs-key', source: 'env' }), +})) + +const { default: DocsCollectionListCommand } = + await import('../../../../src/commands/docs/collection/list.js') + +const DOCS_BASE = 'https://docsapi.helpscout.net' + +describe('hs docs collection list', () => { + afterEach(() => nock.cleanAll()) + + it('sends Basic auth (api key as user, dummy password) to the Docs host', async () => { + const scope = nock(DOCS_BASE, { + reqheaders: { + authorization: `Basic ${Buffer.from('test-docs-key:X').toString('base64')}`, + }, + }) + .get('/v1/collections') + .query(true) + .reply(200, fixture) + + await runCmd(DocsCollectionListCommand, ['--output', 'json']) + expect(scope.isDone()).toBe(true) + }) + + it('returns collections as a JSON array', async () => { + nock(DOCS_BASE).get('/v1/collections').query(true).reply(200, fixture) + + const stdout = await runCmd(DocsCollectionListCommand, ['--output', 'json']) + const output = JSON.parse(stdout) + + expect(Array.isArray(output)).toBe(true) + expect(output).toHaveLength(2) + expect(output[0].name).toBe('General') + expect(output[1].name).toBe('Foire aux Questions') + }) + + it('renders collection names in table format', async () => { + nock(DOCS_BASE).get('/v1/collections').query(true).reply(200, fixture) + + const stdout = await runCmd(DocsCollectionListCommand, [ + '--output', + 'table', + ]) + + expect(stdout).toContain('General') + expect(stdout).toContain('Foire aux Questions') + }) + + it('respects the --limit flag', async () => { + nock(DOCS_BASE).get('/v1/collections').query(true).reply(200, fixture) + + const stdout = await runCmd(DocsCollectionListCommand, [ + '--output', + 'json', + '--limit', + '1', + ]) + const output = JSON.parse(stdout) + + expect(output).toHaveLength(1) + expect(output[0].name).toBe('General') + }) + + it('passes --site as the siteId query param', async () => { + const scope = nock(DOCS_BASE) + .get('/v1/collections') + .query((q) => q.siteId === '52404efc4566740003092640') + .reply(200, fixture) + + await runCmd(DocsCollectionListCommand, [ + '--site', + '52404efc4566740003092640', + '--output', + 'json', + ]) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/fixtures/docs-collections-list.json b/test/fixtures/docs-collections-list.json new file mode 100644 index 0000000..0bbc469 --- /dev/null +++ b/test/fixtures/docs-collections-list.json @@ -0,0 +1,43 @@ +{ + "collections": { + "page": 1, + "pages": 1, + "count": 2, + "items": [ + { + "id": "5214c83d45667acd25394b53", + "siteId": "52404efc4566740003092640", + "number": 33, + "slug": "general", + "visibility": "public", + "order": 1, + "name": "General", + "description": "General help articles", + "publicUrl": "https://example.helpscoutdocs.com/collection/33-general", + "articleCount": 12, + "publishedArticleCount": 10, + "createdBy": 73423, + "updatedBy": 73423, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + }, + { + "id": "5214c83d45667acd25394b54", + "siteId": "52404efc4566740003092640", + "number": 34, + "slug": "faq", + "visibility": "private", + "order": 2, + "name": "Foire aux Questions", + "description": "FAQ en francais", + "publicUrl": "https://example.helpscoutdocs.com/collection/34-faq", + "articleCount": 5, + "publishedArticleCount": 5, + "createdBy": 73423, + "updatedBy": 73423, + "createdAt": "2024-02-01T00:00:00Z", + "updatedAt": "2024-06-02T00:00:00Z" + } + ] + } +} diff --git a/test/lib/docs-auth.test.js b/test/lib/docs-auth.test.js new file mode 100644 index 0000000..5a1d43f --- /dev/null +++ b/test/lib/docs-auth.test.js @@ -0,0 +1,50 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { resolveDocsKey } from '../../src/lib/docs-auth.js' +import { setDocsKey, deleteDocsKey } from '../../src/lib/keychain.js' + +describe('resolveDocsKey', () => { + const ORIG = process.env.HSCLI_DOCS_API_KEY + afterEach(() => { + if (ORIG === undefined) delete process.env.HSCLI_DOCS_API_KEY + else process.env.HSCLI_DOCS_API_KEY = ORIG + }) + + it('prefers the flag over env and keychain', async () => { + process.env.HSCLI_DOCS_API_KEY = 'env-key' + const r = await resolveDocsKey({ + flags: { apiKey: 'flag-key' }, + profile: 'default', + }) + expect(r).toEqual({ apiKey: 'flag-key', source: 'flags' }) + }) + + it('falls back to the HSCLI_DOCS_API_KEY env var', async () => { + process.env.HSCLI_DOCS_API_KEY = 'env-key' + const r = await resolveDocsKey({ profile: 'default' }) + expect(r).toEqual({ apiKey: 'env-key', source: 'env' }) + }) + + it('falls back to the keychain', async () => { + delete process.env.HSCLI_DOCS_API_KEY + const profile = `docs-auth-test-${Date.now()}` + await setDocsKey(profile, 'chain-key') + try { + const r = await resolveDocsKey({ profile }) + expect(r).toEqual({ apiKey: 'chain-key', source: 'keychain' }) + } finally { + await deleteDocsKey(profile) + } + }) + + it('throws a ConfigError when nothing is configured', async () => { + delete process.env.HSCLI_DOCS_API_KEY + await expect( + resolveDocsKey({ profile: `none-${Date.now()}` }), + ).rejects.toThrow(/Docs API key/) + }) + + it('throws when called with no flags, env, or profile', async () => { + delete process.env.HSCLI_DOCS_API_KEY + await expect(resolveDocsKey()).rejects.toThrow(/Docs API key/) + }) +}) diff --git a/test/lib/docs-client.test.js b/test/lib/docs-client.test.js new file mode 100644 index 0000000..5f494ea --- /dev/null +++ b/test/lib/docs-client.test.js @@ -0,0 +1,154 @@ +import { describe, it, expect, afterEach } from 'vitest' +import nock from 'nock' +import { createDocsClient } from '../../src/lib/docs-client.js' + +const BASE = 'https://docsapi.helpscout.net' +const client = (opts) => createDocsClient({ apiKey: 'k', ...opts }) + +describe('docs-client', () => { + afterEach(() => nock.cleanAll()) + + it('sends Basic auth (key:X) and returns parsed JSON', async () => { + const scope = nock(BASE, { + reqheaders: { + authorization: `Basic ${Buffer.from('k:X').toString('base64')}`, + }, + }) + .get('/v1/collections') + .reply(200, { collections: { items: [] } }) + const r = await client().get('collections') + expect(r).toEqual({ collections: { items: [] } }) + expect(scope.isDone()).toBe(true) + }) + + it('refuses non-Docs hosts (host-lock)', async () => { + await expect(client().get('https://evil.example.com/x')).rejects.toThrow( + /non-Help Scout Docs host/, + ) + }) + + it('throws ApiError on a non-2xx response', async () => { + nock(BASE).get('/v1/articles/x').reply(404, { error: 'Not found' }) + await expect(client().get('articles/x')).rejects.toMatchObject({ + statusCode: 404, + }) + }) + + it('throws ApiError on 5xx when retry is disabled', async () => { + nock(BASE).get('/v1/collections').reply(500, { error: 'boom' }) + await expect( + client({ retry: false }).get('collections'), + ).rejects.toMatchObject({ statusCode: 500 }) + }) + + it('returns null on 204 No Content', async () => { + nock(BASE).delete('/v1/articles/x').reply(204) + expect(await client().del('articles/x')).toBeNull() + }) + + it('throws RateLimitError on 429 when retry is disabled', async () => { + nock(BASE) + .get('/v1/collections') + .reply(429, '', { 'x-ratelimit-reset': '7' }) + await expect( + client({ retry: false }).get('collections'), + ).rejects.toMatchObject({ retryAfter: 7 }) + }) + + it('retries after a 429 then succeeds', async () => { + nock(BASE) + .get('/v1/collections') + .reply(429, '', { 'x-ratelimit-reset': '0' }) + nock(BASE).get('/v1/collections').reply(200, { ok: true }) + expect(await client().get('collections')).toEqual({ ok: true }) + }) + + it('gives up with ServiceUnavailableError after repeated 429s', async () => { + for (let i = 0; i < 3; i++) { + nock(BASE) + .get('/v1/collections') + .reply(429, '', { 'x-ratelimit-reset': '0' }) + } + await expect(client().get('collections')).rejects.toThrow(/unavailable/i) + }) + + it('paginates across the Docs envelope', async () => { + nock(BASE) + .get('/v1/collections') + .query({ page: 1 }) + .reply(200, { collections: { page: 1, pages: 2, items: [{ id: 'a' }] } }) + nock(BASE) + .get('/v1/collections') + .query({ page: 2 }) + .reply(200, { collections: { page: 2, pages: 2, items: [{ id: 'b' }] } }) + + const out = [] + for await (const c of client().paginate('collections', {}, 'collections')) { + out.push(c.id) + } + expect(out).toEqual(['a', 'b']) + }) + + it('reports pagination progress via onProgress', async () => { + nock(BASE) + .get('/v1/collections') + .query({ page: 1 }) + .reply(200, { collections: { page: 1, pages: 1, items: [{ id: 'a' }] } }) + + const seen = [] + const gen = client().paginate('collections', {}, 'collections', { + onProgress: (info) => seen.push(info), + }) + for await (const _ of gen) void _ + expect(seen).toEqual([{ page: 1, totalPages: 1 }]) + }) + + it('retries after a 5xx then succeeds', async () => { + nock(BASE).get('/v1/collections').reply(503, { error: 'temporary' }) + nock(BASE).get('/v1/collections').reply(200, { ok: true }) + expect(await client().get('collections')).toEqual({ ok: true }) + }, 15000) + + it('issues POST and PUT write requests', async () => { + nock(BASE).post('/v1/articles', { name: 'A' }).reply(201, { id: '1' }) + nock(BASE).put('/v1/articles/1', { name: 'B' }).reply(200, { id: '1' }) + expect(await client().post('articles', { body: { name: 'A' } })).toEqual({ + id: '1', + }) + expect(await client().put('articles/1', { body: { name: 'B' } })).toEqual({ + id: '1', + }) + }) + + it('handles an empty pagination envelope', async () => { + nock(BASE).get('/v1/collections').query(true).reply(200, {}) + const out = [] + for await (const c of client().paginate('collections', {}, 'collections')) { + out.push(c) + } + expect(out).toEqual([]) + }) + + it('skips null/undefined query params', async () => { + nock(BASE).get('/v1/x').query({ b: '1' }).reply(200, { ok: true }) + expect( + await client().get('x', { query: { a: null, b: 1, c: undefined } }), + ).toEqual({ ok: true }) + }) + + it('uses retry-after / default when 429 lacks x-ratelimit-reset', async () => { + nock(BASE).get('/v1/a').reply(429, '', { 'retry-after': '3' }) + await expect(client({ retry: false }).get('a')).rejects.toMatchObject({ + retryAfter: 3, + }) + nock(BASE).get('/v1/b').reply(429, '') + await expect(client({ retry: false }).get('b')).rejects.toMatchObject({ + retryAfter: 10, + }) + }) + + it('returns null for an empty (non-204) body', async () => { + nock(BASE).get('/v1/empty').reply(200, '') + expect(await client().get('empty')).toBeNull() + }) +}) diff --git a/test/lib/keychain-error.test.js b/test/lib/keychain-error.test.js new file mode 100644 index 0000000..b30c254 --- /dev/null +++ b/test/lib/keychain-error.test.js @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest' + +// The native keyring is present but its backend errors on read (e.g. a locked +// or corrupt keychain). getTokens/getDocsKey must swallow the error and return +// null rather than crash the CLI. +vi.mock('@napi-rs/keyring', () => ({ + Entry: class { + getPassword() { + throw new Error('keyring backend error') + } + setPassword() {} + deletePassword() {} + }, +})) + +const { getTokens, getDocsKey } = await import('../../src/lib/keychain.js') + +describe('keychain when the keyring errors on read', () => { + it('getDocsKey returns null on a keyring read error', async () => { + await expect(getDocsKey('p')).resolves.toBeNull() + }) + + it('getTokens returns null on a keyring read error', async () => { + await expect(getTokens('p')).resolves.toBeNull() + }) +}) diff --git a/test/lib/keychain-fallback.test.js b/test/lib/keychain-fallback.test.js index 6ac4b7a..bc8b043 100644 --- a/test/lib/keychain-fallback.test.js +++ b/test/lib/keychain-fallback.test.js @@ -7,8 +7,15 @@ vi.mock('@napi-rs/keyring', () => { throw new Error('Native module not available') }) -const { getTokens, setTokens, deleteTokens, isKeychainAvailable } = - await import('../../src/lib/keychain.js') +const { + getTokens, + setTokens, + deleteTokens, + isKeychainAvailable, + getDocsKey, + setDocsKey, + deleteDocsKey, +} = await import('../../src/lib/keychain.js') const testProfile = `hscli-nokeychain-${Date.now()}` @@ -38,4 +45,16 @@ describe('keychain when OS keychain is unavailable', () => { it('deleteTokens is a no-op that does not throw', async () => { await expect(deleteTokens(testProfile)).resolves.toBeUndefined() }) + + it('getDocsKey returns null instead of crashing', async () => { + await expect(getDocsKey(testProfile)).resolves.toBeNull() + }) + + it('setDocsKey throws a clear keychain-unavailable error', async () => { + await expect(setDocsKey(testProfile, 'k')).rejects.toThrow(/keychain/i) + }) + + it('deleteDocsKey is a no-op that does not throw', async () => { + await expect(deleteDocsKey(testProfile)).resolves.toBeUndefined() + }) }) diff --git a/test/lib/keychain.test.js b/test/lib/keychain.test.js index 2e8cba8..97d7a45 100644 --- a/test/lib/keychain.test.js +++ b/test/lib/keychain.test.js @@ -4,6 +4,9 @@ import { setTokens, deleteTokens, isKeychainAvailable, + getDocsKey, + setDocsKey, + deleteDocsKey, } from '../../src/lib/keychain.js' const testProfile = `hscli-test-${Date.now()}` @@ -110,4 +113,22 @@ describe('keychain', () => { await expect(deleteTokens(lifecycleProfile)).resolves.toBeUndefined() }) }) + + describe('Docs API key (setDocsKey/getDocsKey/deleteDocsKey)', () => { + it('round-trips and deletes the Docs key', async () => { + const profile = `docs-key-test-${Date.now()}` + await setDocsKey(profile, 'docs-secret') + expect(await getDocsKey(profile)).toBe('docs-secret') + + await deleteDocsKey(profile) + expect(await getDocsKey(profile)).toBeNull() + + // Deleting again should not throw + await expect(deleteDocsKey(profile)).resolves.toBeUndefined() + }) + + it('returns null for a profile with no Docs key', async () => { + expect(await getDocsKey(`no-docs-${Date.now()}`)).toBeNull() + }) + }) }) From 8581a9b7f65e8ff3118b00e6e3d5dc5e408d004c Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 16:23:49 +0200 Subject: [PATCH 2/9] feat(docs): read commands for sites, collections, categories, articles + docs auth - docs collection get; docs site list/get; docs category list - docs article list (--collection|--category, --status); docs article get; docs article search - docs auth: validate (read-only) + store the Docs key in the OS keychain - per-topic help descriptions; 100% coverage; live-verified read-only against prod --- package.json | 9 ++++ src/commands/docs/article/get.js | 32 +++++++++++ src/commands/docs/article/list.js | 55 +++++++++++++++++++ src/commands/docs/article/search.js | 58 ++++++++++++++++++++ src/commands/docs/auth.js | 44 +++++++++++++++ src/commands/docs/category/list.js | 42 +++++++++++++++ src/commands/docs/collection/get.js | 29 ++++++++++ src/commands/docs/site/get.js | 28 ++++++++++ src/commands/docs/site/list.js | 31 +++++++++++ test/commands/docs/article/get.test.js | 43 +++++++++++++++ test/commands/docs/article/list.test.js | 58 ++++++++++++++++++++ test/commands/docs/article/search.test.js | 49 +++++++++++++++++ test/commands/docs/auth.test.js | 66 +++++++++++++++++++++++ test/commands/docs/category/list.test.js | 50 +++++++++++++++++ test/commands/docs/collection/get.test.js | 42 +++++++++++++++ test/commands/docs/site/get.test.js | 40 ++++++++++++++ test/commands/docs/site/list.test.js | 43 +++++++++++++++ test/fixtures/docs-article-get.json | 16 ++++++ test/fixtures/docs-articles-list.json | 37 +++++++++++++ test/fixtures/docs-articles-search.json | 29 ++++++++++ test/fixtures/docs-categories-list.json | 33 ++++++++++++ test/fixtures/docs-collection-get.json | 17 ++++++ test/fixtures/docs-site-get.json | 12 +++++ test/fixtures/docs-sites-list.json | 27 ++++++++++ 24 files changed, 890 insertions(+) create mode 100644 src/commands/docs/article/get.js create mode 100644 src/commands/docs/article/list.js create mode 100644 src/commands/docs/article/search.js create mode 100644 src/commands/docs/auth.js create mode 100644 src/commands/docs/category/list.js create mode 100644 src/commands/docs/collection/get.js create mode 100644 src/commands/docs/site/get.js create mode 100644 src/commands/docs/site/list.js create mode 100644 test/commands/docs/article/get.test.js create mode 100644 test/commands/docs/article/list.test.js create mode 100644 test/commands/docs/article/search.test.js create mode 100644 test/commands/docs/auth.test.js create mode 100644 test/commands/docs/category/list.test.js create mode 100644 test/commands/docs/collection/get.test.js create mode 100644 test/commands/docs/site/get.test.js create mode 100644 test/commands/docs/site/list.test.js create mode 100644 test/fixtures/docs-article-get.json create mode 100644 test/fixtures/docs-articles-list.json create mode 100644 test/fixtures/docs-articles-search.json create mode 100644 test/fixtures/docs-categories-list.json create mode 100644 test/fixtures/docs-collection-get.json create mode 100644 test/fixtures/docs-site-get.json create mode 100644 test/fixtures/docs-sites-list.json diff --git a/package.json b/package.json index 810a974..eb51e0f 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,15 @@ }, "docs:collection": { "description": "Docs collections" + }, + "docs:category": { + "description": "Docs categories" + }, + "docs:article": { + "description": "Docs articles" + }, + "docs:site": { + "description": "Docs sites" } }, "hooks": { diff --git a/src/commands/docs/article/get.js b/src/commands/docs/article/get.js new file mode 100644 index 0000000..97cb25e --- /dev/null +++ b/src/commands/docs/article/get.js @@ -0,0 +1,32 @@ +import { Args } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + status: { header: 'Status' }, + popularity: { header: 'Popularity' }, + publicUrl: { header: 'URL' }, +} + +export default class DocsArticleGetCommand extends DocsBaseCommand { + static description = 'Get a Docs article by id or number' + + static args = { + id: Args.string({ description: 'Article id or number', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article get ', + '<%= config.bin %> docs article get --output json', + ] + + static flags = { ...DocsBaseCommand.baseFlags } + + async run() { + const { args } = await this.parse(DocsArticleGetCommand) + const data = await this.docsClient.get(`articles/${args.id}`) + await this.outputResults(data.article, columns) + } +} diff --git a/src/commands/docs/article/list.js b/src/commands/docs/article/list.js new file mode 100644 index 0000000..aaf342a --- /dev/null +++ b/src/commands/docs/article/list.js @@ -0,0 +1,55 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { collectPages } from '../../../lib/pagination.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + status: { header: 'Status' }, + popularity: { header: 'Popularity' }, + lastPublishedAt: { header: 'Published' }, +} + +export default class DocsArticleListCommand extends DocsBaseCommand { + static description = 'List articles in a Docs collection or category' + + static examples = [ + '<%= config.bin %> docs article list --collection ', + '<%= config.bin %> docs article list --category --status published', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + collection: Flags.string({ + description: 'Collection id', + exclusive: ['category'], + }), + category: Flags.string({ + description: 'Category id', + exclusive: ['collection'], + }), + status: Flags.string({ + description: 'Filter by status', + options: ['all', 'published', 'notpublished'], + }), + limit: Flags.integer({ description: 'Max results to return', default: 50 }), + } + + async run() { + const { flags } = await this.parse(DocsArticleListCommand) + const path = flags.collection + ? `collections/${flags.collection}/articles` + : flags.category + ? `categories/${flags.category}/articles` + : null + if (!path) { + this.error('Provide --collection or --category ', { exit: 64 }) + } + const items = await collectPages( + this.docsClient.paginate(path, { status: flags.status }, 'articles'), + flags.limit, + ) + await this.outputResults(items, columns) + } +} diff --git a/src/commands/docs/article/search.js b/src/commands/docs/article/search.js new file mode 100644 index 0000000..7c07690 --- /dev/null +++ b/src/commands/docs/article/search.js @@ -0,0 +1,58 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { collectPages } from '../../../lib/pagination.js' + +const columns = { + id: { header: 'ID' }, + name: { header: 'Name' }, + collectionId: { header: 'Collection' }, + status: { header: 'Status' }, + visibility: { header: 'Visibility' }, +} + +export default class DocsArticleSearchCommand extends DocsBaseCommand { + static description = 'Search Docs articles by keyword' + + static args = { + query: Args.string({ description: 'Search query', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article search "password reset"', + '<%= config.bin %> docs article search refund --collection ', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + collection: Flags.string({ description: 'Filter by collection id' }), + site: Flags.string({ description: 'Filter by site id' }), + status: Flags.string({ + description: 'Filter by status', + options: ['all', 'published', 'notpublished'], + }), + visibility: Flags.string({ + description: 'Filter by visibility', + options: ['all', 'public', 'private'], + }), + limit: Flags.integer({ description: 'Max results to return', default: 50 }), + } + + async run() { + const { args, flags } = await this.parse(DocsArticleSearchCommand) + const items = await collectPages( + this.docsClient.paginate( + 'search/articles', + { + query: args.query, + collectionId: flags.collection, + siteId: flags.site, + status: flags.status, + visibility: flags.visibility, + }, + 'articles', + ), + flags.limit, + ) + await this.outputResults(items, columns) + } +} diff --git a/src/commands/docs/auth.js b/src/commands/docs/auth.js new file mode 100644 index 0000000..ac64b3b --- /dev/null +++ b/src/commands/docs/auth.js @@ -0,0 +1,44 @@ +import { Flags } from '@oclif/core' +import { password } from '@inquirer/prompts' +import BaseCommand from '../../base-command.js' +import { createDocsClient } from '../../lib/docs-client.js' +import { setDocsKey } from '../../lib/keychain.js' +import { CliError } from '../../lib/errors.js' + +export default class DocsAuthCommand extends BaseCommand { + static description = + 'Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth)' + + static skipAuth = true + + static examples = [ + '<%= config.bin %> docs auth', + '<%= config.bin %> docs auth --api-key ', + ] + + static flags = { + ...BaseCommand.baseFlags, + 'api-key': Flags.string({ + description: 'Docs API key (skips the interactive prompt)', + }), + } + + async run() { + const { flags } = await this.parse(DocsAuthCommand) + const apiKey = + flags['api-key'] || + process.env.HSCLI_DOCS_API_KEY || + (await password({ message: 'Help Scout Docs API key:', mask: true })) + + if (!apiKey) { + throw new CliError('No Docs API key provided', { exitCode: 64 }) + } + + // Validate (read-only) before persisting, so we never store a bad key. + const client = createDocsClient({ apiKey, retry: false }) + await client.get('sites') + + await setDocsKey(this.activeProfile, apiKey) + this.log(`✓ Docs API key stored for profile "${this.activeProfile}"`) + } +} diff --git a/src/commands/docs/category/list.js b/src/commands/docs/category/list.js new file mode 100644 index 0000000..beb4ad5 --- /dev/null +++ b/src/commands/docs/category/list.js @@ -0,0 +1,42 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { collectPages } from '../../../lib/pagination.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + articleCount: { header: 'Articles' }, + order: { header: 'Order' }, +} + +export default class DocsCategoryListCommand extends DocsBaseCommand { + static description = 'List categories within a Docs collection' + + static args = { + collectionId: Args.string({ + description: 'Collection id', + required: true, + }), + } + + static examples = ['<%= config.bin %> docs category list '] + + static flags = { + ...DocsBaseCommand.baseFlags, + limit: Flags.integer({ description: 'Max results to return', default: 50 }), + } + + async run() { + const { args, flags } = await this.parse(DocsCategoryListCommand) + const items = await collectPages( + this.docsClient.paginate( + `collections/${args.collectionId}/categories`, + {}, + 'categories', + ), + flags.limit, + ) + await this.outputResults(items, columns) + } +} diff --git a/src/commands/docs/collection/get.js b/src/commands/docs/collection/get.js new file mode 100644 index 0000000..91ec3c5 --- /dev/null +++ b/src/commands/docs/collection/get.js @@ -0,0 +1,29 @@ +import { Args } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + visibility: { header: 'Visibility' }, + articleCount: { header: 'Articles' }, + publicUrl: { header: 'URL' }, +} + +export default class DocsCollectionGetCommand extends DocsBaseCommand { + static description = 'Get a Docs collection by id or number' + + static args = { + id: Args.string({ description: 'Collection id or number', required: true }), + } + + static examples = ['<%= config.bin %> docs collection get '] + + static flags = { ...DocsBaseCommand.baseFlags } + + async run() { + const { args } = await this.parse(DocsCollectionGetCommand) + const data = await this.docsClient.get(`collections/${args.id}`) + await this.outputResults(data.collection, columns) + } +} diff --git a/src/commands/docs/site/get.js b/src/commands/docs/site/get.js new file mode 100644 index 0000000..8cc9cf0 --- /dev/null +++ b/src/commands/docs/site/get.js @@ -0,0 +1,28 @@ +import { Args } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + title: { header: 'Title' }, + subDomain: { header: 'Subdomain' }, + companyName: { header: 'Company' }, + hasPublicSite: { header: 'Public' }, +} + +export default class DocsSiteGetCommand extends DocsBaseCommand { + static description = 'Get a Docs site by id' + + static args = { + id: Args.string({ description: 'Site id', required: true }), + } + + static examples = ['<%= config.bin %> docs site get '] + + static flags = { ...DocsBaseCommand.baseFlags } + + async run() { + const { args } = await this.parse(DocsSiteGetCommand) + const data = await this.docsClient.get(`sites/${args.id}`) + await this.outputResults(data.site, columns) + } +} diff --git a/src/commands/docs/site/list.js b/src/commands/docs/site/list.js new file mode 100644 index 0000000..cdb1a26 --- /dev/null +++ b/src/commands/docs/site/list.js @@ -0,0 +1,31 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { collectPages } from '../../../lib/pagination.js' + +const columns = { + id: { header: 'ID' }, + title: { header: 'Title' }, + subDomain: { header: 'Subdomain' }, + hasPublicSite: { header: 'Public' }, + updatedAt: { header: 'Updated' }, +} + +export default class DocsSiteListCommand extends DocsBaseCommand { + static description = 'List Docs sites' + + static examples = ['<%= config.bin %> docs site list'] + + static flags = { + ...DocsBaseCommand.baseFlags, + limit: Flags.integer({ description: 'Max results to return', default: 50 }), + } + + async run() { + const { flags } = await this.parse(DocsSiteListCommand) + const items = await collectPages( + this.docsClient.paginate('sites', {}, 'sites'), + flags.limit, + ) + await this.outputResults(items, columns) + } +} diff --git a/test/commands/docs/article/get.test.js b/test/commands/docs/article/get.test.js new file mode 100644 index 0000000..7c96383 --- /dev/null +++ b/test/commands/docs/article/get.test.js @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-article-get.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/get.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5215163545667acd25394b5c' + +describe('hs docs article get', () => { + afterEach(() => nock.cleanAll()) + + it('returns a single article as JSON (including body text)', async () => { + nock(DOCS).get(`/v1/articles/${ID}`).reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, [ID, '--output', 'json'])) + expect(out.name).toBe('Getting started') + expect(out.text).toContain('Welcome') + }) + + it('renders the article in a table', async () => { + nock(DOCS).get(`/v1/articles/${ID}`).reply(200, fixture) + const out = await runCmd(Cmd, [ID, '--output', 'table']) + expect(out).toContain('Getting started') + }) +}) diff --git a/test/commands/docs/article/list.test.js b/test/commands/docs/article/list.test.js new file mode 100644 index 0000000..862a867 --- /dev/null +++ b/test/commands/docs/article/list.test.js @@ -0,0 +1,58 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-articles-list.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/list.js') +const DOCS = 'https://docsapi.helpscout.net' +const CID = '5214c77c45667acd25394b51' +const CAT = '5214c77d45667acd25394b52' + +describe('hs docs article list', () => { + afterEach(() => nock.cleanAll()) + + it('lists a collection’s articles as JSON', async () => { + nock(DOCS) + .get(`/v1/collections/${CID}/articles`) + .query(true) + .reply(200, fixture) + const out = JSON.parse( + await runCmd(Cmd, ['--collection', CID, '--output', 'json']), + ) + expect(out).toHaveLength(2) + expect(out[0].name).toBe('Getting started') + }) + + it('lists a category’s articles as JSON', async () => { + nock(DOCS) + .get(`/v1/categories/${CAT}/articles`) + .query(true) + .reply(200, fixture) + const out = JSON.parse( + await runCmd(Cmd, ['--category', CAT, '--output', 'json']), + ) + expect(out).toHaveLength(2) + }) + + it('errors when neither --collection nor --category is given', async () => { + await expect(Cmd.run([])).rejects.toThrow(/--collection .* or --category/) + }) +}) diff --git a/test/commands/docs/article/search.test.js b/test/commands/docs/article/search.test.js new file mode 100644 index 0000000..de7018f --- /dev/null +++ b/test/commands/docs/article/search.test.js @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-articles-search.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/search.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs article search', () => { + afterEach(() => nock.cleanAll()) + + it('passes the query and returns matches as JSON', async () => { + const scope = nock(DOCS) + .get('/v1/search/articles') + .query((q) => q.query === 'password' && q.page === '1') + .reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, ['password', '--output', 'json'])) + expect(out).toHaveLength(2) + expect(out[0].name).toBe('Getting started') + expect(scope.isDone()).toBe(true) + }) + + it('forwards --collection as collectionId', async () => { + const scope = nock(DOCS) + .get('/v1/search/articles') + .query((q) => q.query === 'refund' && q.collectionId === 'c1') + .reply(200, fixture) + await runCmd(Cmd, ['refund', '--collection', 'c1', '--output', 'json']) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/docs/auth.test.js b/test/commands/docs/auth.test.js new file mode 100644 index 0000000..2fb195d --- /dev/null +++ b/test/commands/docs/auth.test.js @@ -0,0 +1,66 @@ +import nock from 'nock' +import { runCmd } from '../../helpers.js' + +vi.mock('../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) + +vi.mock('../../../src/lib/keychain.js', () => ({ + getTokens: vi.fn(), + setTokens: vi.fn(), + deleteTokens: vi.fn(), + isKeychainAvailable: vi.fn().mockReturnValue(true), + setDocsKey: vi.fn().mockResolvedValue(undefined), + getDocsKey: vi.fn(), + deleteDocsKey: vi.fn(), +})) + +vi.mock('@inquirer/prompts', () => ({ + password: vi.fn().mockResolvedValue('prompted-key'), +})) + +const { setDocsKey } = await import('../../../src/lib/keychain.js') +const { password } = await import('@inquirer/prompts') +const { default: Cmd } = await import('../../../src/commands/docs/auth.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs auth', () => { + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('validates and stores a key passed via --api-key', async () => { + nock(DOCS) + .get('/v1/sites') + .reply(200, { sites: { items: [] } }) + const out = await runCmd(Cmd, ['--api-key', 'mykey']) + expect(setDocsKey).toHaveBeenCalledWith('default', 'mykey') + expect(password).not.toHaveBeenCalled() + expect(out).toContain('stored') + }) + + it('prompts for the key when no flag/env is set', async () => { + delete process.env.HSCLI_DOCS_API_KEY + nock(DOCS) + .get('/v1/sites') + .reply(200, { sites: { items: [] } }) + await runCmd(Cmd, []) + expect(password).toHaveBeenCalled() + expect(setDocsKey).toHaveBeenCalledWith('default', 'prompted-key') + }) + + it('does not store the key when validation fails', async () => { + nock(DOCS).get('/v1/sites').reply(401, { error: 'Invalid API Key' }) + await runCmd(Cmd, ['--api-key', 'bad']) + expect(setDocsKey).not.toHaveBeenCalled() + }) + + it('errors when no key is provided at all', async () => { + delete process.env.HSCLI_DOCS_API_KEY + password.mockResolvedValueOnce('') + await expect(Cmd.run([])).rejects.toThrow(/No Docs API key/) + expect(setDocsKey).not.toHaveBeenCalled() + }) +}) diff --git a/test/commands/docs/category/list.test.js b/test/commands/docs/category/list.test.js new file mode 100644 index 0000000..39cbaf6 --- /dev/null +++ b/test/commands/docs/category/list.test.js @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-categories-list.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/category/list.js') +const DOCS = 'https://docsapi.helpscout.net' +const CID = '5214c83d45667acd25394b53' + +describe('hs docs category list', () => { + afterEach(() => nock.cleanAll()) + + it('returns categories for a collection as JSON', async () => { + nock(DOCS) + .get(`/v1/collections/${CID}/categories`) + .query(true) + .reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, [CID, '--output', 'json'])) + expect(out).toHaveLength(2) + expect(out[0].name).toBe('Getting Started') + }) + + it('renders category names in a table', async () => { + nock(DOCS) + .get(`/v1/collections/${CID}/categories`) + .query(true) + .reply(200, fixture) + const out = await runCmd(Cmd, [CID, '--output', 'table']) + expect(out).toContain('Getting Started') + expect(out).toContain('Billing') + }) +}) diff --git a/test/commands/docs/collection/get.test.js b/test/commands/docs/collection/get.test.js new file mode 100644 index 0000000..73ce387 --- /dev/null +++ b/test/commands/docs/collection/get.test.js @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-collection-get.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/collection/get.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs collection get', () => { + afterEach(() => nock.cleanAll()) + + it('returns a single collection as JSON', async () => { + nock(DOCS).get('/v1/collections/33').reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, ['33', '--output', 'json'])) + expect(out.name).toBe('General') + expect(out.id).toBe('5214c83d45667acd25394b53') + }) + + it('renders the collection in a table', async () => { + nock(DOCS).get('/v1/collections/33').reply(200, fixture) + const out = await runCmd(Cmd, ['33', '--output', 'table']) + expect(out).toContain('General') + }) +}) diff --git a/test/commands/docs/site/get.test.js b/test/commands/docs/site/get.test.js new file mode 100644 index 0000000..d1565e2 --- /dev/null +++ b/test/commands/docs/site/get.test.js @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync(join(__dirname, '../../../fixtures/docs-site-get.json'), 'utf8'), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/site/get.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '566807879033603f7da26a9d' + +describe('hs docs site get', () => { + afterEach(() => nock.cleanAll()) + + it('returns a single site as JSON', async () => { + nock(DOCS).get(`/v1/sites/${ID}`).reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, [ID, '--output', 'json'])) + expect(out.title).toBe('Acme Docs') + expect(out.subDomain).toBe('acme') + }) + + it('renders the site in a table', async () => { + nock(DOCS).get(`/v1/sites/${ID}`).reply(200, fixture) + const out = await runCmd(Cmd, [ID, '--output', 'table']) + expect(out).toContain('Acme Docs') + }) +}) diff --git a/test/commands/docs/site/list.test.js b/test/commands/docs/site/list.test.js new file mode 100644 index 0000000..1234ceb --- /dev/null +++ b/test/commands/docs/site/list.test.js @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixture = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-sites-list.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/site/list.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs site list', () => { + afterEach(() => nock.cleanAll()) + + it('returns sites as a JSON array', async () => { + nock(DOCS).get('/v1/sites').query(true).reply(200, fixture) + const out = JSON.parse(await runCmd(Cmd, ['--output', 'json'])) + expect(out).toHaveLength(2) + expect(out[0].title).toBe('Acme Docs') + }) + + it('renders site titles in a table', async () => { + nock(DOCS).get('/v1/sites').query(true).reply(200, fixture) + const out = await runCmd(Cmd, ['--output', 'table']) + expect(out).toContain('Acme Docs') + expect(out).toContain('acme') + }) +}) diff --git a/test/fixtures/docs-article-get.json b/test/fixtures/docs-article-get.json new file mode 100644 index 0000000..904172e --- /dev/null +++ b/test/fixtures/docs-article-get.json @@ -0,0 +1,16 @@ +{ + "article": { + "id": "5215163545667acd25394b5c", + "number": 121, + "collectionId": "5214c77c45667acd25394b51", + "status": "published", + "hasDraft": false, + "name": "Getting started", + "text": "

Welcome to the docs.

", + "publicUrl": "https://example.helpscoutdocs.com/article/121-getting-started", + "popularity": 4.3, + "viewCount": 237, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-05-01T00:00:00Z" + } +} diff --git a/test/fixtures/docs-articles-list.json b/test/fixtures/docs-articles-list.json new file mode 100644 index 0000000..91fac08 --- /dev/null +++ b/test/fixtures/docs-articles-list.json @@ -0,0 +1,37 @@ +{ + "articles": { + "page": 1, + "pages": 1, + "count": 2, + "items": [ + { + "id": "5215163545667acd25394b5c", + "number": 121, + "collectionId": "5214c77c45667acd25394b51", + "status": "published", + "hasDraft": false, + "name": "Getting started", + "publicUrl": "https://example.helpscoutdocs.com/article/121-getting-started", + "popularity": 4.3, + "viewCount": 237, + "lastPublishedAt": "2024-05-01T00:00:00Z", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-05-01T00:00:00Z" + }, + { + "id": "5215163545667acd25394b5d", + "number": 122, + "collectionId": "5214c77c45667acd25394b51", + "status": "notpublished", + "hasDraft": true, + "name": "Resetting your password", + "publicUrl": "https://example.helpscoutdocs.com/article/122-reset", + "popularity": 1.1, + "viewCount": 12, + "lastPublishedAt": null, + "createdAt": "2024-02-01T00:00:00Z", + "updatedAt": "2024-04-01T00:00:00Z" + } + ] + } +} diff --git a/test/fixtures/docs-articles-search.json b/test/fixtures/docs-articles-search.json new file mode 100644 index 0000000..7d83bc9 --- /dev/null +++ b/test/fixtures/docs-articles-search.json @@ -0,0 +1,29 @@ +{ + "articles": { + "page": 1, + "pages": 1, + "count": 2, + "items": [ + { + "id": "5215163545667acd25394b5c", + "siteId": "52404efc4566740003092640", + "collectionId": "5214c77c45667acd25394b51", + "categoryIds": ["5214c77d45667acd25394b52"], + "slug": "getting-started", + "name": "Getting started", + "status": "published", + "visibility": "public" + }, + { + "id": "5215163545667acd25394b5e", + "siteId": "52404efc4566740003092640", + "collectionId": "5214c77c45667acd25394b51", + "categoryIds": [], + "slug": "billing-faq", + "name": "Billing FAQ", + "status": "published", + "visibility": "public" + } + ] + } +} diff --git a/test/fixtures/docs-categories-list.json b/test/fixtures/docs-categories-list.json new file mode 100644 index 0000000..1f16eff --- /dev/null +++ b/test/fixtures/docs-categories-list.json @@ -0,0 +1,33 @@ +{ + "categories": { + "page": 1, + "pages": 1, + "count": 2, + "items": [ + { + "id": "5214c77d45667acd25394b52", + "collectionId": "5214c83d45667acd25394b53", + "number": 7, + "slug": "getting-started", + "name": "Getting Started", + "order": 1, + "articleCount": 4, + "publicUrl": "https://example.helpscoutdocs.com/category/7-getting-started", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + }, + { + "id": "5214c77d45667acd25394b99", + "collectionId": "5214c83d45667acd25394b53", + "number": 8, + "slug": "billing", + "name": "Billing", + "order": 2, + "articleCount": 3, + "publicUrl": "https://example.helpscoutdocs.com/category/8-billing", + "createdAt": "2024-01-05T00:00:00Z", + "updatedAt": "2024-06-05T00:00:00Z" + } + ] + } +} diff --git a/test/fixtures/docs-collection-get.json b/test/fixtures/docs-collection-get.json new file mode 100644 index 0000000..deecc85 --- /dev/null +++ b/test/fixtures/docs-collection-get.json @@ -0,0 +1,17 @@ +{ + "collection": { + "id": "5214c83d45667acd25394b53", + "siteId": "52404efc4566740003092640", + "number": 33, + "slug": "general", + "visibility": "public", + "order": 1, + "name": "General", + "description": "General help articles", + "publicUrl": "https://example.helpscoutdocs.com/collection/33-general", + "articleCount": 12, + "publishedArticleCount": 10, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + } +} diff --git a/test/fixtures/docs-site-get.json b/test/fixtures/docs-site-get.json new file mode 100644 index 0000000..37dadd5 --- /dev/null +++ b/test/fixtures/docs-site-get.json @@ -0,0 +1,12 @@ +{ + "site": { + "id": "566807879033603f7da26a9d", + "companyName": "Acme", + "title": "Acme Docs", + "subDomain": "acme", + "hasPublicSite": true, + "hasContactForm": false, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + } +} diff --git a/test/fixtures/docs-sites-list.json b/test/fixtures/docs-sites-list.json new file mode 100644 index 0000000..c1a4273 --- /dev/null +++ b/test/fixtures/docs-sites-list.json @@ -0,0 +1,27 @@ +{ + "sites": { + "page": 1, + "pages": 1, + "count": 2, + "items": [ + { + "id": "566807879033603f7da26a9d", + "companyName": "Acme", + "title": "Acme Docs", + "subDomain": "acme", + "hasPublicSite": true, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + }, + { + "id": "5683fae09033603f7da2c63d", + "companyName": "Acme", + "title": "Acme FR", + "subDomain": "acme-fr", + "hasPublicSite": true, + "createdAt": "2024-02-01T00:00:00Z", + "updatedAt": "2024-06-02T00:00:00Z" + } + ] + } +} From a250ebdb5693ce9922dcf171bcccca74297e7d52 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 16:27:10 +0200 Subject: [PATCH 3/9] docs(site): document the Docs API surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Generated command reference now includes the docs topic (knowledge-base badge + guide link); 77 commands. - New guides/docs.mdx walkthrough (auth, sites, collections, categories, articles, search) + sidebar entry. - README: Docs API is shipped (not 'planned') — intro, commands table row, quick-start example. --- README.md | 10 +- docs/commands.md | 157 +++++++++++++++++- scripts/gen-commands.mjs | 2 + website/astro.config.mjs | 1 + website/src/content/docs/guides/docs.mdx | 55 ++++++ .../src/content/docs/reference/commands.mdx | 20 ++- 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 website/src/content/docs/guides/docs.mdx diff --git a/README.md b/README.md index 47fa77f..e28bd1d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ Command-line interface for [Help Scout](https://www.helpscout.com/). Covers the **Mailbox API 2.0** (conversations, customers, mailboxes, users, tags, -workflows, webhooks, reports) plus **Beacon** HMAC/snippet utilities and a full -account **backup**. Docs API support is planned. +workflows, webhooks, reports) and the **Docs API** (knowledge base: sites, collections, +categories, articles, search), plus **Beacon** HMAC/snippet utilities and a full +account **backup**. JSON output, deterministic exit codes, and a raw `hscli api` escape hatch make it a clean way to drive Help Scout from CI pipelines and **AI agents** (Claude Code, @@ -46,6 +47,10 @@ hscli conv list # List conversations hscli conv reply 123 --body "Thanks" # Reply to a conversation hscli customer create --email user@example.com --first Jane hscli backup --out ~/hs-backup # Full account backup (incremental on re-run) + +# Docs knowledge base (separate per-user API key: `hscli docs auth` or HSCLI_DOCS_API_KEY) +hscli docs auth # Store your Docs API key in the keychain +hscli docs article search "refund" # Search the knowledge base ``` For CI/CD, use the non-interactive client-credentials flow: @@ -68,6 +73,7 @@ HSCLI_APP_ID=... HSCLI_APP_SECRET=... hscli auth login --client-credentials | `hscli webhook` | `list`, `get`, `create`, `delete` | | `hscli report` | `company`, `user`, `conversations`, `beacon` | | `hscli beacon` | `sign`, `verify`, `embed`, `identify-snippet` — HMAC + snippet utilities for Beacon Secure Mode | +| `hscli docs` | `auth`, `site`, `collection`, `category`, `article` — read/search the Docs knowledge base (separate per-user API key) | | `hscli profile` | `list`, `use`, `current` | | `hscli config` | `get`, `set`, `list`, `validate` | | `hscli alias` | `set`, `list`, `unset` — custom command shortcuts | diff --git a/docs/commands.md b/docs/commands.md index 976f059..eeb9cf3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.8.0 (68 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.8.1 (77 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level @@ -835,6 +835,161 @@ hscli customer update 42 --email new@example.com --company Acme hscli customer update 42 --job-title "VP of Engineering" ``` +## hscli docs + +### `hscli docs article get` + +Get a Docs article by id or number + +``` +hscli docs article get [flags] +``` + +Examples: + +```bash +hscli docs article get +hscli docs article get --output json +``` + +### `hscli docs article list` + +List articles in a Docs collection or category + +``` +hscli docs article list [flags] +``` + +- `--collection ` — Collection id +- `--category ` — Category id +- `--status ` — Filter by status +- `--limit ` — Max results to return + +Examples: + +```bash +hscli docs article list --collection +hscli docs article list --category --status published +``` + +### `hscli docs article search` + +Search Docs articles by keyword + +``` +hscli docs article search [flags] +``` + +- `--collection ` — Filter by collection id +- `--site ` — Filter by site id +- `--status ` — Filter by status +- `--visibility ` — Filter by visibility +- `--limit ` — Max results to return + +Examples: + +```bash +hscli docs article search "password reset" +hscli docs article search refund --collection +``` + +### `hscli docs auth` + +Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth) + +``` +hscli docs auth [flags] +``` + +- `--api-key ` — Docs API key (skips the interactive prompt) + +Examples: + +```bash +hscli docs auth +hscli docs auth --api-key +``` + +### `hscli docs category list` + +List categories within a Docs collection + +``` +hscli docs category list [flags] +``` + +- `--limit ` — Max results to return + +Examples: + +```bash +hscli docs category list +``` + +### `hscli docs collection get` + +Get a Docs collection by id or number + +``` +hscli docs collection get [flags] +``` + +Examples: + +```bash +hscli docs collection get +``` + +### `hscli docs collection list` + +List Docs collections + +``` +hscli docs collection list [flags] +``` + +- `--limit ` — Max results to return +- `--site ` — Filter by Site id +- `--visibility ` — Filter by visibility + +Examples: + +```bash +hscli docs collection list +hscli docs collection list --site +hscli docs collection list --visibility public --output json +``` + +### `hscli docs site get` + +Get a Docs site by id + +``` +hscli docs site get [flags] +``` + +Examples: + +```bash +hscli docs site get +``` + +### `hscli docs site list` + +List Docs sites + +``` +hscli docs site list [flags] +``` + +- `--limit ` — Max results to return + +Examples: + +```bash +hscli docs site list +``` + ## hscli mailbox ### `hscli mailbox fields` diff --git a/scripts/gen-commands.mjs b/scripts/gen-commands.mjs index addc81f..ae1cb86 100644 --- a/scripts/gen-commands.mjs +++ b/scripts/gen-commands.mjs @@ -107,6 +107,7 @@ const TOPIC_BADGE = { api: ['escape hatch', 'badge--coral badge--dot'], backup: ['archive', 'badge--dot'], doctor: ['diagnostics', 'badge--dot'], + docs: ['knowledge base', 'badge--dot'], } const TOPIC_BLURB = { @@ -130,6 +131,7 @@ const TOPIC_BLURB = { backup: 'Dump your whole account to JSON with incremental refresh, resume, deletion detection, and attachments. See the [Backups guide](/guides/backups/).', doctor: 'Diagnose your environment, auth, and connectivity.', + docs: 'Manage your Help Scout Docs knowledge base — sites, collections, categories, and articles. Uses a separate Docs API key (`hscli docs auth`). See the [Docs guide](/guides/docs/).', } const renderTopicTable = (list) => { diff --git a/website/astro.config.mjs b/website/astro.config.mjs index 8c551fc..a95c506 100644 --- a/website/astro.config.mjs +++ b/website/astro.config.mjs @@ -81,6 +81,7 @@ export default defineConfig({ { label: 'Mailboxes', slug: 'guides/mailboxes' }, { label: 'Tags & team', slug: 'guides/tags-and-team' }, { label: 'Beacon', slug: 'guides/beacon' }, + { label: 'Docs (knowledge base)', slug: 'guides/docs' }, ], }, { diff --git a/website/src/content/docs/guides/docs.mdx b/website/src/content/docs/guides/docs.mdx new file mode 100644 index 0000000..a1ed7ee --- /dev/null +++ b/website/src/content/docs/guides/docs.mdx @@ -0,0 +1,55 @@ +--- +title: Docs (knowledge base) +description: Manage your Help Scout Docs knowledge base — sites, collections, categories, and articles — from the terminal. +--- + +The `docs` group talks to the **Help Scout Docs API**, a separate product with its own +per-user API key, independent of your Mailbox/Inbox login. + +## Authenticate + +Find your Docs API key in the web app under your profile → **Authentication → API Keys** +(you need the _"Docs: Create new, edit settings & Collections"_ permission), then store it +once in your OS keychain: + +```bash frame="terminal" title="zsh — hscli" +$ hscli docs auth +✓ Docs API key stored for profile "default" +``` + +For CI, pass it through the environment instead — no keychain needed: + +```bash frame="terminal" +HSCLI_DOCS_API_KEY="$HELPSCOUT_DOCS_KEY" hscli docs site list +``` + +## Sites & collections + +```bash frame="terminal" +hscli docs site list +hscli docs site get +hscli docs collection list --site --visibility public +hscli docs collection get +hscli docs category list +``` + +## Articles + +```bash frame="terminal" +# list articles in a collection (or a category) +hscli docs article list --collection --status published + +# fetch one article — the body HTML lives in the `text` field +hscli docs article get --output json --jq '.[].text' + +# full-text search across the knowledge base +hscli docs article search "password reset" --collection +``` + +:::note +`hscli docs` is read-focused today — `list`, `get`, and `search`. Article and collection +writes (create, update, drafts) are on the roadmap. The Docs API is rate-limited per +10-minute window; large pulls back off automatically on `429`. +::: + +See the [command reference](/reference/commands/#docs) for every command and flag. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 04fe9c2..96bdea2 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 68 commands in `hscli` v0.8.0. +This page lists all 77 commands in `hscli` v0.8.1. ## alias @@ -109,6 +109,24 @@ Search, read, create, and update the people behind your conversations. See the [ | `customer search ` | Search customers | `--limit` | | `customer update ` | Update a customer | `--email` `--first` `--last` `--company` `--phone` `--job-title` | +## docs + +knowledge base + +Manage your Help Scout Docs knowledge base — sites, collections, categories, and articles. Uses a separate Docs API key (`hscli docs auth`). See the [Docs guide](/guides/docs/). + +| Command | Description | Key flags | +| --- | --- | --- | +| `docs article get ` | Get a Docs article by id or number | — | +| `docs article list` | List articles in a Docs collection or category | `--collection` `--category` `--status` `--limit` | +| `docs article search ` | Search Docs articles by keyword | `--collection` `--site` `--status` `--visibility` `--limit` | +| `docs auth` | Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth) | `--api-key` | +| `docs category list ` | List categories within a Docs collection | `--limit` | +| `docs collection get ` | Get a Docs collection by id or number | — | +| `docs collection list` | List Docs collections | `--limit` `--site` `--visibility` | +| `docs site get ` | Get a Docs site by id | — | +| `docs site list` | List Docs sites | `--limit` | + ## mailbox inboxes From 9fb46dfa83e95fd5c8a164acedf90ab9c2dfb02f Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 16:40:41 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(docs):=20article=20write=20commands=20?= =?UTF-8?q?=E2=80=94=20create,=20update,=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs article create (POST, reload=true returns the new article), update (PUT, partial fields, errors if none), delete (DELETE 204, confirm unless --yes). - docs-input helper: @file text bodies + comma-separated lists. - 100% coverage (628 tests). Live-verified a full create -> update -> delete cycle against a prod Docs account using a throwaway notpublished article, then removed it; existing content untouched. - Docs site guide + generated reference + README updated for the write surface. --- README.md | 2 +- docs/commands.md | 62 ++++++++++++- src/commands/docs/article/create.js | 60 ++++++++++++ src/commands/docs/article/delete.js | 41 +++++++++ src/commands/docs/article/update.js | 62 +++++++++++++ src/lib/docs-input.js | 27 ++++++ test/commands/docs/article/create.test.js | 92 +++++++++++++++++++ test/commands/docs/article/delete.test.js | 48 ++++++++++ test/commands/docs/article/update.test.js | 79 ++++++++++++++++ test/fixtures/docs-article-body.html | 2 + test/fixtures/docs-article-created.json | 14 +++ test/lib/docs-input.test.js | 30 ++++++ website/src/content/docs/guides/docs.mdx | 17 +++- .../src/content/docs/reference/commands.mdx | 5 +- 14 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 src/commands/docs/article/create.js create mode 100644 src/commands/docs/article/delete.js create mode 100644 src/commands/docs/article/update.js create mode 100644 src/lib/docs-input.js create mode 100644 test/commands/docs/article/create.test.js create mode 100644 test/commands/docs/article/delete.test.js create mode 100644 test/commands/docs/article/update.test.js create mode 100644 test/fixtures/docs-article-body.html create mode 100644 test/fixtures/docs-article-created.json create mode 100644 test/lib/docs-input.test.js diff --git a/README.md b/README.md index e28bd1d..e6c3c9e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ HSCLI_APP_ID=... HSCLI_APP_SECRET=... hscli auth login --client-credentials | `hscli webhook` | `list`, `get`, `create`, `delete` | | `hscli report` | `company`, `user`, `conversations`, `beacon` | | `hscli beacon` | `sign`, `verify`, `embed`, `identify-snippet` — HMAC + snippet utilities for Beacon Secure Mode | -| `hscli docs` | `auth`, `site`, `collection`, `category`, `article` — read/search the Docs knowledge base (separate per-user API key) | +| `hscli docs` | `auth`, `site`, `collection`, `category`, `article` — read/search the Docs knowledge base + `article create`/`update`/`delete` (separate per-user API key) | | `hscli profile` | `list`, `use`, `current` | | `hscli config` | `get`, `set`, `list`, `validate` | | `hscli alias` | `set`, `list`, `unset` — custom command shortcuts | diff --git a/docs/commands.md b/docs/commands.md index eeb9cf3..bc1a44a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.8.1 (77 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.8.1 (80 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level @@ -837,6 +837,46 @@ hscli customer update 42 --job-title "VP of Engineering" ## hscli docs +### `hscli docs article create` + +Create a Docs article + +``` +hscli docs article create [flags] +``` + +- `--collection ` _(required)_ — Collection id +- `--name ` _(required)_ — Article name (unique within the collection) +- `--text ` _(required)_ — Article body — text/HTML, or @file +- `--status ` — Article status +- `--slug ` — SEO slug (auto-generated if omitted) +- `--categories ` — Comma-separated category ids +- `--keywords ` — Comma-separated keywords + +Examples: + +```bash +hscli docs article create --collection --name "Title" --text "

Body

" +hscli docs article create --collection --name "Title" --text @article.html --status published +``` + +### `hscli docs article delete` + +Delete a Docs article + +``` +hscli docs article delete [flags] +``` + +- `-y, --yes` — Skip confirmation prompt + +Examples: + +```bash +hscli docs article delete +hscli docs article delete --yes +``` + ### `hscli docs article get` Get a Docs article by id or number @@ -893,6 +933,26 @@ hscli docs article search "password reset" hscli docs article search refund --collection ``` +### `hscli docs article update` + +Update a Docs article + +``` +hscli docs article update [flags] +``` + +- `--name ` — New article name +- `--text ` — New body — text/HTML, or @file +- `--status ` — Article status +- `--slug ` — SEO slug + +Examples: + +```bash +hscli docs article update --name "New title" +hscli docs article update --text @article.html --status published +``` + ### `hscli docs auth` Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth) diff --git a/src/commands/docs/article/create.js b/src/commands/docs/article/create.js new file mode 100644 index 0000000..d200d9c --- /dev/null +++ b/src/commands/docs/article/create.js @@ -0,0 +1,60 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { readText, csvList } from '../../../lib/docs-input.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + status: { header: 'Status' }, + publicUrl: { header: 'URL' }, +} + +export default class DocsArticleCreateCommand extends DocsBaseCommand { + static description = 'Create a Docs article' + + static examples = [ + '<%= config.bin %> docs article create --collection --name "Title" --text "

Body

"', + '<%= config.bin %> docs article create --collection --name "Title" --text @article.html --status published', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + collection: Flags.string({ description: 'Collection id', required: true }), + name: Flags.string({ + description: 'Article name (unique within the collection)', + required: true, + }), + text: Flags.string({ + description: 'Article body — text/HTML, or @file', + required: true, + }), + status: Flags.string({ + description: 'Article status', + options: ['published', 'notpublished'], + default: 'notpublished', + }), + slug: Flags.string({ description: 'SEO slug (auto-generated if omitted)' }), + categories: Flags.string({ description: 'Comma-separated category ids' }), + keywords: Flags.string({ description: 'Comma-separated keywords' }), + } + + async run() { + const { flags } = await this.parse(DocsArticleCreateCommand) + const body = { + collectionId: flags.collection, + name: flags.name, + text: readText(flags.text), + status: flags.status, + slug: flags.slug, + categories: csvList(flags.categories), + keywords: csvList(flags.keywords), + } + // reload=true returns the created article (with its new id) in the response. + const data = await this.docsClient.post('articles', { + query: { reload: true }, + body, + }) + await this.outputResults(data.article, columns) + } +} diff --git a/src/commands/docs/article/delete.js b/src/commands/docs/article/delete.js new file mode 100644 index 0000000..1ab0680 --- /dev/null +++ b/src/commands/docs/article/delete.js @@ -0,0 +1,41 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { confirmAction } from '../../../lib/confirm.js' + +export default class DocsArticleDeleteCommand extends DocsBaseCommand { + static description = 'Delete a Docs article' + + static args = { + id: Args.string({ description: 'Article id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article delete ', + '<%= config.bin %> docs article delete --yes', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + } + + async run() { + const { args, flags } = await this.parse(DocsArticleDeleteCommand) + + const confirmed = await confirmAction( + `Delete Docs article ${args.id}? This cannot be undone.`, + flags.yes, + ) + if (!confirmed) { + this.log('Cancelled.') + return + } + + await this.docsClient.del(`articles/${args.id}`) + this.log(`Deleted article ${args.id}`) + } +} diff --git a/src/commands/docs/article/update.js b/src/commands/docs/article/update.js new file mode 100644 index 0000000..7726c61 --- /dev/null +++ b/src/commands/docs/article/update.js @@ -0,0 +1,62 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { readText } from '../../../lib/docs-input.js' +import { CliError } from '../../../lib/errors.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + status: { header: 'Status' }, + publicUrl: { header: 'URL' }, +} + +export default class DocsArticleUpdateCommand extends DocsBaseCommand { + static description = 'Update a Docs article' + + static args = { + id: Args.string({ description: 'Article id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article update --name "New title"', + '<%= config.bin %> docs article update --text @article.html --status published', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + name: Flags.string({ description: 'New article name' }), + text: Flags.string({ description: 'New body — text/HTML, or @file' }), + status: Flags.string({ + description: 'Article status', + options: ['published', 'notpublished'], + }), + slug: Flags.string({ description: 'SEO slug' }), + } + + async run() { + const { args, flags } = await this.parse(DocsArticleUpdateCommand) + const body = {} + if (flags.name != null) body.name = flags.name + if (flags.text != null) body.text = readText(flags.text) + if (flags.status != null) body.status = flags.status + if (flags.slug != null) body.slug = flags.slug + + if (Object.keys(body).length === 0) { + throw new CliError( + 'Provide at least one field to update (--name/--text/--status/--slug)', + { exitCode: 64 }, + ) + } + + const data = await this.docsClient.put(`articles/${args.id}`, { + query: { reload: true }, + body, + }) + if (data?.article) { + await this.outputResults(data.article, columns) + } else { + this.log(`Updated article ${args.id}`) + } + } +} diff --git a/src/lib/docs-input.js b/src/lib/docs-input.js new file mode 100644 index 0000000..fdb6d6f --- /dev/null +++ b/src/lib/docs-input.js @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs' + +/** + * Resolve article text: a leading `@` reads the rest as a file path; otherwise + * the value is returned as-is. Useful for long HTML bodies. + * @param {string | undefined} value + * @returns {string | undefined} + */ +export function readText(value) { + if (value && value.startsWith('@')) { + return readFileSync(value.slice(1), 'utf8') + } + return value +} + +/** + * Split a comma-separated flag into a trimmed array, or `undefined` when empty. + * @param {string | undefined} value + * @returns {string[] | undefined} + */ +export function csvList(value) { + if (!value) return undefined + return value + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} diff --git a/test/commands/docs/article/create.test.js b/test/commands/docs/article/create.test.js new file mode 100644 index 0000000..e341ae7 --- /dev/null +++ b/test/commands/docs/article/create.test.js @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const created = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-article-created.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/create.js') +const DOCS = 'https://docsapi.helpscout.net' +const CID = '5214c77c45667acd25394b51' + +describe('hs docs article create', () => { + afterEach(() => nock.cleanAll()) + + it('POSTs the article (reload=true) and prints the created article', async () => { + const scope = nock(DOCS) + .post( + '/v1/articles', + (b) => + b.collectionId === CID && + b.name === 'Test article' && + b.text === '

Body

' && + b.status === 'notpublished', + ) + .query({ reload: 'true' }) + .reply(201, created) + + const out = JSON.parse( + await runCmd(Cmd, [ + '--collection', + CID, + '--name', + 'Test article', + '--text', + '

Body

', + '--output', + 'json', + ]), + ) + expect(out.id).toBe('5215163545667acd25394bff') + expect(out.name).toBe('Test article') + expect(scope.isDone()).toBe(true) + }) + + it('forwards --status, --categories and --keywords', async () => { + const scope = nock(DOCS) + .post( + '/v1/articles', + (b) => + b.status === 'published' && + Array.isArray(b.categories) && + b.categories[0] === 'cat1' && + b.keywords[1] === 'two', + ) + .query({ reload: 'true' }) + .reply(201, created) + + await runCmd(Cmd, [ + '--collection', + CID, + '--name', + 'Test article', + '--text', + 'x', + '--status', + 'published', + '--categories', + 'cat1, cat2', + '--keywords', + 'one, two', + '--output', + 'json', + ]) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/docs/article/delete.test.js b/test/commands/docs/article/delete.test.js new file mode 100644 index 0000000..2747b0a --- /dev/null +++ b/test/commands/docs/article/delete.test.js @@ -0,0 +1,48 @@ +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) +vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })) + +const { confirm } = await import('@inquirer/prompts') +const { default: Cmd } = + await import('../../../../src/commands/docs/article/delete.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5215163545667acd25394b5c' + +describe('hs docs article delete', () => { + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('deletes without prompting when --yes is passed', async () => { + const scope = nock(DOCS).delete(`/v1/articles/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID, '--yes']) + expect(out).toContain(`Deleted article ${ID}`) + expect(confirm).not.toHaveBeenCalled() + expect(scope.isDone()).toBe(true) + }) + + it('deletes after the user confirms', async () => { + confirm.mockResolvedValueOnce(true) + const scope = nock(DOCS).delete(`/v1/articles/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID]) + expect(confirm).toHaveBeenCalled() + expect(out).toContain(`Deleted article ${ID}`) + expect(scope.isDone()).toBe(true) + }) + + it('does nothing when the user declines', async () => { + confirm.mockResolvedValueOnce(false) + const out = await runCmd(Cmd, [ID]) + expect(out).toContain('Cancelled') + // No DELETE was issued — nock has no interceptor, so a request would throw. + }) +}) diff --git a/test/commands/docs/article/update.test.js b/test/commands/docs/article/update.test.js new file mode 100644 index 0000000..bbf2d0a --- /dev/null +++ b/test/commands/docs/article/update.test.js @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const article = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-article-get.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/update.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5215163545667acd25394b5c' + +describe('hs docs article update', () => { + afterEach(() => nock.cleanAll()) + + it('PUTs only the provided fields and prints the updated article', async () => { + const scope = nock(DOCS) + .put( + `/v1/articles/${ID}`, + (b) => b.status === 'published' && !('name' in b), + ) + .query({ reload: 'true' }) + .reply(200, article) + + const out = JSON.parse( + await runCmd(Cmd, [ID, '--status', 'published', '--output', 'json']), + ) + expect(out.name).toBe('Getting started') + expect(scope.isDone()).toBe(true) + }) + + it('logs success when the API returns no body', async () => { + nock(DOCS) + .put(`/v1/articles/${ID}`) + .query({ reload: 'true' }) + .reply(200, '') + const out = await runCmd(Cmd, [ID, '--name', 'Renamed']) + expect(out).toContain(`Updated article ${ID}`) + }) + + it('updates text and slug together', async () => { + const scope = nock(DOCS) + .put( + `/v1/articles/${ID}`, + (b) => b.text === '

new

' && b.slug === 'new-slug', + ) + .query({ reload: 'true' }) + .reply(200, article) + await runCmd(Cmd, [ + ID, + '--text', + '

new

', + '--slug', + 'new-slug', + '--output', + 'json', + ]) + expect(scope.isDone()).toBe(true) + }) + + it('errors when no fields are given', async () => { + await expect(Cmd.run([ID])).rejects.toThrow(/at least one field/) + }) +}) diff --git a/test/fixtures/docs-article-body.html b/test/fixtures/docs-article-body.html new file mode 100644 index 0000000..23404cf --- /dev/null +++ b/test/fixtures/docs-article-body.html @@ -0,0 +1,2 @@ +

From a file

+

This article body was read from a file via the @path convention.

diff --git a/test/fixtures/docs-article-created.json b/test/fixtures/docs-article-created.json new file mode 100644 index 0000000..cdb86fb --- /dev/null +++ b/test/fixtures/docs-article-created.json @@ -0,0 +1,14 @@ +{ + "article": { + "id": "5215163545667acd25394bff", + "number": 201, + "collectionId": "5214c77c45667acd25394b51", + "status": "notpublished", + "hasDraft": false, + "name": "Test article", + "text": "

Body

", + "publicUrl": "https://example.helpscoutdocs.com/article/201-test-article", + "createdAt": "2024-06-04T00:00:00Z", + "updatedAt": "2024-06-04T00:00:00Z" + } +} diff --git a/test/lib/docs-input.test.js b/test/lib/docs-input.test.js new file mode 100644 index 0000000..b8e1c57 --- /dev/null +++ b/test/lib/docs-input.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { readText, csvList } from '../../src/lib/docs-input.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +describe('docs-input', () => { + it('readText returns a plain string unchanged', () => { + expect(readText('

hi

')).toBe('

hi

') + }) + + it('readText reads a file for an @path value', () => { + const p = join(__dirname, '../fixtures/docs-article-body.html') + expect(readText('@' + p)).toContain('From a file') + }) + + it('readText passes through undefined', () => { + expect(readText(undefined)).toBeUndefined() + }) + + it('csvList splits and trims', () => { + expect(csvList('a, b ,c')).toEqual(['a', 'b', 'c']) + }) + + it('csvList returns undefined for empty/missing input', () => { + expect(csvList(undefined)).toBeUndefined() + expect(csvList('')).toBeUndefined() + }) +}) diff --git a/website/src/content/docs/guides/docs.mdx b/website/src/content/docs/guides/docs.mdx index a1ed7ee..7cddaa0 100644 --- a/website/src/content/docs/guides/docs.mdx +++ b/website/src/content/docs/guides/docs.mdx @@ -46,10 +46,21 @@ hscli docs article get --output json --jq '.[].text' hscli docs article search "password reset" --collection ``` +## Create, update & delete articles + +```bash frame="terminal" +hscli docs article create --collection --name "Title" --text @article.html --status notpublished +hscli docs article update --status published +hscli docs article delete --yes +``` + +New articles default to `notpublished`, so nothing goes public by accident. `--text` takes +inline HTML or `@file`, and `delete` prompts for confirmation unless you pass `--yes`. + :::note -`hscli docs` is read-focused today — `list`, `get`, and `search`. Article and collection -writes (create, update, drafts) are on the roadmap. The Docs API is rate-limited per -10-minute window; large pulls back off automatically on `429`. +Article writes (create / update / delete) are supported. Collection & category writes and +article drafts are on the roadmap. The Docs API is rate-limited per 10-minute window; large +pulls back off automatically on `429`. ::: See the [command reference](/reference/commands/#docs) for every command and flag. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 96bdea2..b98adc0 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 77 commands in `hscli` v0.8.1. +This page lists all 80 commands in `hscli` v0.8.1. ## alias @@ -117,9 +117,12 @@ Manage your Help Scout Docs knowledge base — sites, collections, categories, a | Command | Description | Key flags | | --- | --- | --- | +| `docs article create` | Create a Docs article | `--collection` `--name` `--text` `--status` `--slug` `--categories` `--keywords` | +| `docs article delete ` | Delete a Docs article | `--yes` | | `docs article get ` | Get a Docs article by id or number | — | | `docs article list` | List articles in a Docs collection or category | `--collection` `--category` `--status` `--limit` | | `docs article search ` | Search Docs articles by keyword | `--collection` `--site` `--status` `--visibility` `--limit` | +| `docs article update ` | Update a Docs article | `--name` `--text` `--status` `--slug` | | `docs auth` | Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth) | `--api-key` | | `docs category list ` | List categories within a Docs collection | `--limit` | | `docs collection get ` | Get a Docs collection by id or number | — | From 35b17949cb0c197595332b88fed3c051dbbf9e70 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 16:43:11 +0200 Subject: [PATCH 5/9] style: format the docs-article-body.html test fixture (prettier) --- test/fixtures/docs-article-body.html | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/fixtures/docs-article-body.html b/test/fixtures/docs-article-body.html index 23404cf..e407f91 100644 --- a/test/fixtures/docs-article-body.html +++ b/test/fixtures/docs-article-body.html @@ -1,2 +1,4 @@

From a file

-

This article body was read from a file via the @path convention.

+

+ This article body was read from a file via the @path convention. +

From 3d5250126c09335e245d23c1f2eafaec8fd8adb2 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 21:50:09 +0200 Subject: [PATCH 6/9] feat(docs): add collection/category writes and article drafts Complete the Docs API write surface: - docs collection create/update/delete - docs category create/update/delete - docs article save-draft/delete-draft Create/update use reload=true to return the resulting object; the Docs API requires --name on every collection/category update. Deletes prompt for confirmation unless --yes is passed. Drafts stage changes without affecting the published article. Regenerate the command reference (88 commands), update the Docs guide and README. 100% coverage retained. --- README.md | 2 +- docs/commands.md | 147 +++++++++++++++++- src/commands/docs/article/delete-draft.js | 42 +++++ src/commands/docs/article/save-draft.js | 32 ++++ src/commands/docs/category/create.js | 46 ++++++ src/commands/docs/category/delete.js | 41 +++++ src/commands/docs/category/update.js | 53 +++++++ src/commands/docs/collection/create.js | 46 ++++++ src/commands/docs/collection/delete.js | 41 +++++ src/commands/docs/collection/update.js | 55 +++++++ .../docs/article/delete-draft.test.js | 47 ++++++ test/commands/docs/article/save-draft.test.js | 45 ++++++ test/commands/docs/category/create.test.js | 69 ++++++++ test/commands/docs/category/delete.test.js | 47 ++++++ test/commands/docs/category/update.test.js | 59 +++++++ test/commands/docs/collection/create.test.js | 69 ++++++++ test/commands/docs/collection/delete.test.js | 47 ++++++ test/commands/docs/collection/update.test.js | 62 ++++++++ test/fixtures/docs-category-created.json | 14 ++ website/src/content/docs/guides/docs.mdx | 32 +++- .../src/content/docs/reference/commands.mdx | 10 +- 21 files changed, 1000 insertions(+), 6 deletions(-) create mode 100644 src/commands/docs/article/delete-draft.js create mode 100644 src/commands/docs/article/save-draft.js create mode 100644 src/commands/docs/category/create.js create mode 100644 src/commands/docs/category/delete.js create mode 100644 src/commands/docs/category/update.js create mode 100644 src/commands/docs/collection/create.js create mode 100644 src/commands/docs/collection/delete.js create mode 100644 src/commands/docs/collection/update.js create mode 100644 test/commands/docs/article/delete-draft.test.js create mode 100644 test/commands/docs/article/save-draft.test.js create mode 100644 test/commands/docs/category/create.test.js create mode 100644 test/commands/docs/category/delete.test.js create mode 100644 test/commands/docs/category/update.test.js create mode 100644 test/commands/docs/collection/create.test.js create mode 100644 test/commands/docs/collection/delete.test.js create mode 100644 test/commands/docs/collection/update.test.js create mode 100644 test/fixtures/docs-category-created.json diff --git a/README.md b/README.md index e6c3c9e..ce9fe18 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ HSCLI_APP_ID=... HSCLI_APP_SECRET=... hscli auth login --client-credentials | `hscli webhook` | `list`, `get`, `create`, `delete` | | `hscli report` | `company`, `user`, `conversations`, `beacon` | | `hscli beacon` | `sign`, `verify`, `embed`, `identify-snippet` — HMAC + snippet utilities for Beacon Secure Mode | -| `hscli docs` | `auth`, `site`, `collection`, `category`, `article` — read/search the Docs knowledge base + `article create`/`update`/`delete` (separate per-user API key) | +| `hscli docs` | `auth`, `site`, `collection`, `category`, `article` — read/search + full CRUD on collections, categories & articles (incl. drafts) in the Docs knowledge base (separate per-user API key) | | `hscli profile` | `list`, `use`, `current` | | `hscli config` | `get`, `set`, `list`, `validate` | | `hscli alias` | `set`, `list`, `unset` — custom command shortcuts | diff --git a/docs/commands.md b/docs/commands.md index bc1a44a..94eb173 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.8.1 (80 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.8.1 (88 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level @@ -877,6 +877,23 @@ hscli docs article delete hscli docs article delete --yes ``` +### `hscli docs article delete-draft` + +Discard the draft of a Docs article (published text is kept) + +``` +hscli docs article delete-draft [flags] +``` + +- `-y, --yes` — Skip confirmation prompt + +Examples: + +```bash +hscli docs article delete-draft +hscli docs article delete-draft --yes +``` + ### `hscli docs article get` Get a Docs article by id or number @@ -912,6 +929,23 @@ hscli docs article list --collection hscli docs article list --category --status published ``` +### `hscli docs article save-draft` + +Save a draft for a Docs article (does not publish) + +``` +hscli docs article save-draft [flags] +``` + +- `--text ` _(required)_ — Draft body — text/HTML, or @file + +Examples: + +```bash +hscli docs article save-draft --text "

Work in progress

" +hscli docs article save-draft --text @draft.html +``` + ### `hscli docs article search` Search Docs articles by keyword @@ -970,6 +1004,42 @@ hscli docs auth hscli docs auth --api-key ``` +### `hscli docs category create` + +Create a Docs category within a collection + +``` +hscli docs category create [flags] +``` + +- `--collection ` _(required)_ — Collection id +- `--name ` _(required)_ — Category name (unique within the collection) +- `--visibility ` — Visibility +- `--order ` — Display order + +Examples: + +```bash +hscli docs category create --collection --name "Billing" +``` + +### `hscli docs category delete` + +Delete a Docs category + +``` +hscli docs category delete [flags] +``` + +- `-y, --yes` — Skip confirmation prompt + +Examples: + +```bash +hscli docs category delete +hscli docs category delete --yes +``` + ### `hscli docs category list` List categories within a Docs collection @@ -986,6 +1056,61 @@ Examples: hscli docs category list ``` +### `hscli docs category update` + +Update a Docs category + +``` +hscli docs category update [flags] +``` + +- `--name ` _(required)_ — Category name (required by the Docs API on update) +- `--visibility ` — Visibility +- `--order ` — Display order + +Examples: + +```bash +hscli docs category update --name "Renamed" +hscli docs category update --name "Billing" --order 2 +``` + +### `hscli docs collection create` + +Create a Docs collection + +``` +hscli docs collection create [flags] +``` + +- `--site ` _(required)_ — Site id +- `--name ` _(required)_ — Collection name (unique per account) +- `--visibility ` — Visibility +- `--order ` — Display order + +Examples: + +```bash +hscli docs collection create --site --name "Guides" +``` + +### `hscli docs collection delete` + +Delete a Docs collection + +``` +hscli docs collection delete [flags] +``` + +- `-y, --yes` — Skip confirmation prompt + +Examples: + +```bash +hscli docs collection delete +hscli docs collection delete --yes +``` + ### `hscli docs collection get` Get a Docs collection by id or number @@ -1020,6 +1145,26 @@ hscli docs collection list --site hscli docs collection list --visibility public --output json ``` +### `hscli docs collection update` + +Update a Docs collection + +``` +hscli docs collection update [flags] +``` + +- `--name ` _(required)_ — Collection name (required by the Docs API on update) +- `--visibility ` — Visibility +- `--order ` — Display order +- `--site ` — Move the collection to this site id + +Examples: + +```bash +hscli docs collection update --name "Renamed" +hscli docs collection update --name "Guides" --visibility private +``` + ### `hscli docs site get` Get a Docs site by id diff --git a/src/commands/docs/article/delete-draft.js b/src/commands/docs/article/delete-draft.js new file mode 100644 index 0000000..58c10d9 --- /dev/null +++ b/src/commands/docs/article/delete-draft.js @@ -0,0 +1,42 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { confirmAction } from '../../../lib/confirm.js' + +export default class DocsArticleDeleteDraftCommand extends DocsBaseCommand { + static description = + 'Discard the draft of a Docs article (published text is kept)' + + static args = { + id: Args.string({ description: 'Article id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article delete-draft ', + '<%= config.bin %> docs article delete-draft --yes', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + } + + async run() { + const { args, flags } = await this.parse(DocsArticleDeleteDraftCommand) + + const confirmed = await confirmAction( + `Discard the draft for article ${args.id}? This cannot be undone.`, + flags.yes, + ) + if (!confirmed) { + this.log('Cancelled.') + return + } + + await this.docsClient.del(`articles/${args.id}/drafts`) + this.log(`Discarded draft for article ${args.id}`) + } +} diff --git a/src/commands/docs/article/save-draft.js b/src/commands/docs/article/save-draft.js new file mode 100644 index 0000000..ca08b3c --- /dev/null +++ b/src/commands/docs/article/save-draft.js @@ -0,0 +1,32 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { readText } from '../../../lib/docs-input.js' + +export default class DocsArticleSaveDraftCommand extends DocsBaseCommand { + static description = 'Save a draft for a Docs article (does not publish)' + + static args = { + id: Args.string({ description: 'Article id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs article save-draft --text "

Work in progress

"', + '<%= config.bin %> docs article save-draft --text @draft.html', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + text: Flags.string({ + description: 'Draft body — text/HTML, or @file', + required: true, + }), + } + + async run() { + const { args, flags } = await this.parse(DocsArticleSaveDraftCommand) + await this.docsClient.put(`articles/${args.id}/drafts`, { + body: { text: readText(flags.text) }, + }) + this.log(`Saved draft for article ${args.id}`) + } +} diff --git a/src/commands/docs/category/create.js b/src/commands/docs/category/create.js new file mode 100644 index 0000000..fdf2624 --- /dev/null +++ b/src/commands/docs/category/create.js @@ -0,0 +1,46 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + articleCount: { header: 'Articles' }, + order: { header: 'Order' }, +} + +export default class DocsCategoryCreateCommand extends DocsBaseCommand { + static description = 'Create a Docs category within a collection' + + static examples = [ + '<%= config.bin %> docs category create --collection --name "Billing"', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + collection: Flags.string({ description: 'Collection id', required: true }), + name: Flags.string({ + description: 'Category name (unique within the collection)', + required: true, + }), + visibility: Flags.string({ + description: 'Visibility', + options: ['public', 'private'], + }), + order: Flags.integer({ description: 'Display order' }), + } + + async run() { + const { flags } = await this.parse(DocsCategoryCreateCommand) + const data = await this.docsClient.post('categories', { + query: { reload: true }, + body: { + collectionId: flags.collection, + name: flags.name, + visibility: flags.visibility, + order: flags.order, + }, + }) + await this.outputResults(data.category, columns) + } +} diff --git a/src/commands/docs/category/delete.js b/src/commands/docs/category/delete.js new file mode 100644 index 0000000..99d9be1 --- /dev/null +++ b/src/commands/docs/category/delete.js @@ -0,0 +1,41 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { confirmAction } from '../../../lib/confirm.js' + +export default class DocsCategoryDeleteCommand extends DocsBaseCommand { + static description = 'Delete a Docs category' + + static args = { + id: Args.string({ description: 'Category id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs category delete ', + '<%= config.bin %> docs category delete --yes', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + } + + async run() { + const { args, flags } = await this.parse(DocsCategoryDeleteCommand) + + const confirmed = await confirmAction( + `Delete Docs category ${args.id}? This cannot be undone.`, + flags.yes, + ) + if (!confirmed) { + this.log('Cancelled.') + return + } + + await this.docsClient.del(`categories/${args.id}`) + this.log(`Deleted category ${args.id}`) + } +} diff --git a/src/commands/docs/category/update.js b/src/commands/docs/category/update.js new file mode 100644 index 0000000..65bae80 --- /dev/null +++ b/src/commands/docs/category/update.js @@ -0,0 +1,53 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + articleCount: { header: 'Articles' }, + order: { header: 'Order' }, +} + +export default class DocsCategoryUpdateCommand extends DocsBaseCommand { + static description = 'Update a Docs category' + + static args = { + id: Args.string({ description: 'Category id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs category update --name "Renamed"', + '<%= config.bin %> docs category update --name "Billing" --order 2', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + name: Flags.string({ + description: 'Category name (required by the Docs API on update)', + required: true, + }), + visibility: Flags.string({ + description: 'Visibility', + options: ['public', 'private'], + }), + order: Flags.integer({ description: 'Display order' }), + } + + async run() { + const { args, flags } = await this.parse(DocsCategoryUpdateCommand) + const data = await this.docsClient.put(`categories/${args.id}`, { + query: { reload: true }, + body: { + name: flags.name, + visibility: flags.visibility, + order: flags.order, + }, + }) + if (data?.category) { + await this.outputResults(data.category, columns) + } else { + this.log(`Updated category ${args.id}`) + } + } +} diff --git a/src/commands/docs/collection/create.js b/src/commands/docs/collection/create.js new file mode 100644 index 0000000..3ba5a70 --- /dev/null +++ b/src/commands/docs/collection/create.js @@ -0,0 +1,46 @@ +import { Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + visibility: { header: 'Visibility' }, + articleCount: { header: 'Articles' }, +} + +export default class DocsCollectionCreateCommand extends DocsBaseCommand { + static description = 'Create a Docs collection' + + static examples = [ + '<%= config.bin %> docs collection create --site --name "Guides"', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + site: Flags.string({ description: 'Site id', required: true }), + name: Flags.string({ + description: 'Collection name (unique per account)', + required: true, + }), + visibility: Flags.string({ + description: 'Visibility', + options: ['public', 'private'], + }), + order: Flags.integer({ description: 'Display order' }), + } + + async run() { + const { flags } = await this.parse(DocsCollectionCreateCommand) + const data = await this.docsClient.post('collections', { + query: { reload: true }, + body: { + siteId: flags.site, + name: flags.name, + visibility: flags.visibility, + order: flags.order, + }, + }) + await this.outputResults(data.collection, columns) + } +} diff --git a/src/commands/docs/collection/delete.js b/src/commands/docs/collection/delete.js new file mode 100644 index 0000000..e1299f4 --- /dev/null +++ b/src/commands/docs/collection/delete.js @@ -0,0 +1,41 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' +import { confirmAction } from '../../../lib/confirm.js' + +export default class DocsCollectionDeleteCommand extends DocsBaseCommand { + static description = 'Delete a Docs collection' + + static args = { + id: Args.string({ description: 'Collection id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs collection delete ', + '<%= config.bin %> docs collection delete --yes', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + } + + async run() { + const { args, flags } = await this.parse(DocsCollectionDeleteCommand) + + const confirmed = await confirmAction( + `Delete Docs collection ${args.id} and all its articles? This cannot be undone.`, + flags.yes, + ) + if (!confirmed) { + this.log('Cancelled.') + return + } + + await this.docsClient.del(`collections/${args.id}`) + this.log(`Deleted collection ${args.id}`) + } +} diff --git a/src/commands/docs/collection/update.js b/src/commands/docs/collection/update.js new file mode 100644 index 0000000..9face98 --- /dev/null +++ b/src/commands/docs/collection/update.js @@ -0,0 +1,55 @@ +import { Args, Flags } from '@oclif/core' +import DocsBaseCommand from '../../../docs-base-command.js' + +const columns = { + id: { header: 'ID' }, + number: { header: '#' }, + name: { header: 'Name' }, + visibility: { header: 'Visibility' }, + articleCount: { header: 'Articles' }, +} + +export default class DocsCollectionUpdateCommand extends DocsBaseCommand { + static description = 'Update a Docs collection' + + static args = { + id: Args.string({ description: 'Collection id', required: true }), + } + + static examples = [ + '<%= config.bin %> docs collection update --name "Renamed"', + '<%= config.bin %> docs collection update --name "Guides" --visibility private', + ] + + static flags = { + ...DocsBaseCommand.baseFlags, + name: Flags.string({ + description: 'Collection name (required by the Docs API on update)', + required: true, + }), + visibility: Flags.string({ + description: 'Visibility', + options: ['public', 'private'], + }), + order: Flags.integer({ description: 'Display order' }), + site: Flags.string({ description: 'Move the collection to this site id' }), + } + + async run() { + const { args, flags } = await this.parse(DocsCollectionUpdateCommand) + const data = await this.docsClient.put(`collections/${args.id}`, { + query: { reload: true }, + body: { + name: flags.name, + visibility: flags.visibility, + order: flags.order, + siteId: flags.site, + }, + }) + if (data?.collection) { + await this.outputResults(data.collection, columns) + } else { + this.log(`Updated collection ${args.id}`) + } + } +} diff --git a/test/commands/docs/article/delete-draft.test.js b/test/commands/docs/article/delete-draft.test.js new file mode 100644 index 0000000..b4c8a60 --- /dev/null +++ b/test/commands/docs/article/delete-draft.test.js @@ -0,0 +1,47 @@ +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) +vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })) + +const { confirm } = await import('@inquirer/prompts') +const { default: Cmd } = + await import('../../../../src/commands/docs/article/delete-draft.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5215163545667acd25394b5c' + +describe('hs docs article delete-draft', () => { + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('discards the draft without prompting when --yes is passed', async () => { + const scope = nock(DOCS).delete(`/v1/articles/${ID}/drafts`).reply(200, '') + const out = await runCmd(Cmd, [ID, '--yes']) + expect(out).toContain(`Discarded draft for article ${ID}`) + expect(confirm).not.toHaveBeenCalled() + expect(scope.isDone()).toBe(true) + }) + + it('discards the draft after the user confirms', async () => { + confirm.mockResolvedValueOnce(true) + const scope = nock(DOCS).delete(`/v1/articles/${ID}/drafts`).reply(200, '') + const out = await runCmd(Cmd, [ID]) + expect(confirm).toHaveBeenCalled() + expect(out).toContain(`Discarded draft for article ${ID}`) + expect(scope.isDone()).toBe(true) + }) + + it('does nothing when the user declines', async () => { + confirm.mockResolvedValueOnce(false) + const out = await runCmd(Cmd, [ID]) + expect(out).toContain('Cancelled') + }) +}) diff --git a/test/commands/docs/article/save-draft.test.js b/test/commands/docs/article/save-draft.test.js new file mode 100644 index 0000000..dbe1993 --- /dev/null +++ b/test/commands/docs/article/save-draft.test.js @@ -0,0 +1,45 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const html = readFileSync( + join(__dirname, '../../../fixtures/docs-article-body.html'), + 'utf8', +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/article/save-draft.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5215163545667acd25394b5c' + +describe('hs docs article save-draft', () => { + afterEach(() => nock.cleanAll()) + + it('PUTs the draft text and logs success', async () => { + const scope = nock(DOCS) + .put(`/v1/articles/${ID}/drafts`, (b) => b.text === '

draft

') + .reply(200, '') + const out = await runCmd(Cmd, [ID, '--text', '

draft

']) + expect(out).toContain(`Saved draft for article ${ID}`) + expect(scope.isDone()).toBe(true) + }) + + it('reads the draft body from a @file', async () => { + const scope = nock(DOCS) + .put(`/v1/articles/${ID}/drafts`, (b) => b.text === html) + .reply(200, '') + await runCmd(Cmd, [ID, '--text', '@test/fixtures/docs-article-body.html']) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/docs/category/create.test.js b/test/commands/docs/category/create.test.js new file mode 100644 index 0000000..989430c --- /dev/null +++ b/test/commands/docs/category/create.test.js @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const category = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-category-created.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/category/create.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs category create', () => { + afterEach(() => nock.cleanAll()) + + it('POSTs the category with reload and prints the result', async () => { + const scope = nock(DOCS) + .post( + '/v1/categories', + (b) => + b.collectionId === 'col1' && + b.name === 'Billing' && + b.visibility === 'public' && + b.order === 3, + ) + .query({ reload: 'true' }) + .reply(201, category) + + const out = JSON.parse( + await runCmd(Cmd, [ + '--collection', + 'col1', + '--name', + 'Billing', + '--visibility', + 'public', + '--order', + '3', + '--output', + 'json', + ]), + ) + expect(out.name).toBe('Billing') + expect(scope.isDone()).toBe(true) + }) + + it('omits optional fields when not provided', async () => { + const scope = nock(DOCS) + .post('/v1/categories', (b) => !('visibility' in b) && !('order' in b)) + .query({ reload: 'true' }) + .reply(201, category) + await runCmd(Cmd, ['--collection', 'col1', '--name', 'Billing']) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/docs/category/delete.test.js b/test/commands/docs/category/delete.test.js new file mode 100644 index 0000000..ac0f5e0 --- /dev/null +++ b/test/commands/docs/category/delete.test.js @@ -0,0 +1,47 @@ +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) +vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })) + +const { confirm } = await import('@inquirer/prompts') +const { default: Cmd } = + await import('../../../../src/commands/docs/category/delete.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5214c77d45667acd25394bff' + +describe('hs docs category delete', () => { + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('deletes without prompting when --yes is passed', async () => { + const scope = nock(DOCS).delete(`/v1/categories/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID, '--yes']) + expect(out).toContain(`Deleted category ${ID}`) + expect(confirm).not.toHaveBeenCalled() + expect(scope.isDone()).toBe(true) + }) + + it('deletes after the user confirms', async () => { + confirm.mockResolvedValueOnce(true) + const scope = nock(DOCS).delete(`/v1/categories/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID]) + expect(confirm).toHaveBeenCalled() + expect(out).toContain(`Deleted category ${ID}`) + expect(scope.isDone()).toBe(true) + }) + + it('does nothing when the user declines', async () => { + confirm.mockResolvedValueOnce(false) + const out = await runCmd(Cmd, [ID]) + expect(out).toContain('Cancelled') + }) +}) diff --git a/test/commands/docs/category/update.test.js b/test/commands/docs/category/update.test.js new file mode 100644 index 0000000..cfd631b --- /dev/null +++ b/test/commands/docs/category/update.test.js @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const category = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-category-created.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/category/update.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5214c77d45667acd25394bff' + +describe('hs docs category update', () => { + afterEach(() => nock.cleanAll()) + + it('PUTs the category and prints the updated result', async () => { + const scope = nock(DOCS) + .put(`/v1/categories/${ID}`, (b) => b.name === 'Renamed' && b.order === 5) + .query({ reload: 'true' }) + .reply(200, category) + const out = JSON.parse( + await runCmd(Cmd, [ + ID, + '--name', + 'Renamed', + '--order', + '5', + '--output', + 'json', + ]), + ) + expect(out.name).toBe('Billing') + expect(scope.isDone()).toBe(true) + }) + + it('logs success when the API returns no body', async () => { + nock(DOCS) + .put(`/v1/categories/${ID}`) + .query({ reload: 'true' }) + .reply(200, '') + const out = await runCmd(Cmd, [ID, '--name', 'Renamed']) + expect(out).toContain(`Updated category ${ID}`) + }) +}) diff --git a/test/commands/docs/collection/create.test.js b/test/commands/docs/collection/create.test.js new file mode 100644 index 0000000..0671e42 --- /dev/null +++ b/test/commands/docs/collection/create.test.js @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const collection = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-collection-get.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/collection/create.js') +const DOCS = 'https://docsapi.helpscout.net' + +describe('hs docs collection create', () => { + afterEach(() => nock.cleanAll()) + + it('POSTs the collection with reload and prints the result', async () => { + const scope = nock(DOCS) + .post( + '/v1/collections', + (b) => + b.siteId === 'site1' && + b.name === 'General' && + b.visibility === 'private' && + b.order === 2, + ) + .query({ reload: 'true' }) + .reply(201, collection) + + const out = JSON.parse( + await runCmd(Cmd, [ + '--site', + 'site1', + '--name', + 'General', + '--visibility', + 'private', + '--order', + '2', + '--output', + 'json', + ]), + ) + expect(out.name).toBe('General') + expect(scope.isDone()).toBe(true) + }) + + it('omits optional fields when not provided', async () => { + const scope = nock(DOCS) + .post('/v1/collections', (b) => !('visibility' in b) && !('order' in b)) + .query({ reload: 'true' }) + .reply(201, collection) + await runCmd(Cmd, ['--site', 'site1', '--name', 'General']) + expect(scope.isDone()).toBe(true) + }) +}) diff --git a/test/commands/docs/collection/delete.test.js b/test/commands/docs/collection/delete.test.js new file mode 100644 index 0000000..4cf857b --- /dev/null +++ b/test/commands/docs/collection/delete.test.js @@ -0,0 +1,47 @@ +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) +vi.mock('@inquirer/prompts', () => ({ confirm: vi.fn() })) + +const { confirm } = await import('@inquirer/prompts') +const { default: Cmd } = + await import('../../../../src/commands/docs/collection/delete.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5214c83d45667acd25394b53' + +describe('hs docs collection delete', () => { + afterEach(() => { + nock.cleanAll() + vi.clearAllMocks() + }) + + it('deletes without prompting when --yes is passed', async () => { + const scope = nock(DOCS).delete(`/v1/collections/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID, '--yes']) + expect(out).toContain(`Deleted collection ${ID}`) + expect(confirm).not.toHaveBeenCalled() + expect(scope.isDone()).toBe(true) + }) + + it('deletes after the user confirms', async () => { + confirm.mockResolvedValueOnce(true) + const scope = nock(DOCS).delete(`/v1/collections/${ID}`).reply(204) + const out = await runCmd(Cmd, [ID]) + expect(confirm).toHaveBeenCalled() + expect(out).toContain(`Deleted collection ${ID}`) + expect(scope.isDone()).toBe(true) + }) + + it('does nothing when the user declines', async () => { + confirm.mockResolvedValueOnce(false) + const out = await runCmd(Cmd, [ID]) + expect(out).toContain('Cancelled') + }) +}) diff --git a/test/commands/docs/collection/update.test.js b/test/commands/docs/collection/update.test.js new file mode 100644 index 0000000..c4eff5e --- /dev/null +++ b/test/commands/docs/collection/update.test.js @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import nock from 'nock' +import { runCmd } from '../../../helpers.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const collection = JSON.parse( + readFileSync( + join(__dirname, '../../../fixtures/docs-collection-get.json'), + 'utf8', + ), +) + +vi.mock('../../../../src/lib/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ activeProfile: 'default' }), + getProfileConfig: vi.fn().mockReturnValue(undefined), +})) +vi.mock('../../../../src/lib/docs-auth.js', () => ({ + resolveDocsKey: vi.fn().mockReturnValue({ apiKey: 'k', source: 'env' }), +})) + +const { default: Cmd } = + await import('../../../../src/commands/docs/collection/update.js') +const DOCS = 'https://docsapi.helpscout.net' +const ID = '5214c83d45667acd25394b53' + +describe('hs docs collection update', () => { + afterEach(() => nock.cleanAll()) + + it('PUTs the collection and prints the updated result', async () => { + const scope = nock(DOCS) + .put( + `/v1/collections/${ID}`, + (b) => b.name === 'Renamed' && b.siteId === 'site2', + ) + .query({ reload: 'true' }) + .reply(200, collection) + const out = JSON.parse( + await runCmd(Cmd, [ + ID, + '--name', + 'Renamed', + '--site', + 'site2', + '--output', + 'json', + ]), + ) + expect(out.name).toBe('General') + expect(scope.isDone()).toBe(true) + }) + + it('logs success when the API returns no body', async () => { + nock(DOCS) + .put(`/v1/collections/${ID}`) + .query({ reload: 'true' }) + .reply(200, '') + const out = await runCmd(Cmd, [ID, '--name', 'Renamed']) + expect(out).toContain(`Updated collection ${ID}`) + }) +}) diff --git a/test/fixtures/docs-category-created.json b/test/fixtures/docs-category-created.json new file mode 100644 index 0000000..d05c894 --- /dev/null +++ b/test/fixtures/docs-category-created.json @@ -0,0 +1,14 @@ +{ + "category": { + "id": "5214c77d45667acd25394bff", + "collectionId": "5214c83d45667acd25394b53", + "number": 9, + "slug": "billing", + "name": "Billing", + "order": 1, + "articleCount": 0, + "publicUrl": "https://example.helpscoutdocs.com/category/9-billing", + "createdAt": "2024-06-01T00:00:00Z", + "updatedAt": "2024-06-01T00:00:00Z" + } +} diff --git a/website/src/content/docs/guides/docs.mdx b/website/src/content/docs/guides/docs.mdx index 7cddaa0..8c76b90 100644 --- a/website/src/content/docs/guides/docs.mdx +++ b/website/src/content/docs/guides/docs.mdx @@ -57,10 +57,36 @@ hscli docs article delete --yes New articles default to `notpublished`, so nothing goes public by accident. `--text` takes inline HTML or `@file`, and `delete` prompts for confirmation unless you pass `--yes`. +### Drafts + +Stage changes without touching the published article — useful for reviewing before going live: + +```bash frame="terminal" +hscli docs article save-draft --text @draft.html # stage a draft +hscli docs article delete-draft --yes # discard it; published text is kept +``` + +## Manage collections & categories + +```bash frame="terminal" +hscli docs collection create --site --name "Guides" --visibility public +hscli docs collection update --name "Renamed" +hscli docs collection delete --yes + +hscli docs category create --collection --name "Billing" +hscli docs category update --name "Renamed" --order 2 +hscli docs category delete --yes +``` + +The Docs API requires `--name` on every collection/category update, even when you only mean +to change `--visibility` or `--order`. Deletes prompt for confirmation unless you pass `--yes`. + +:::caution +Deleting a collection removes every article inside it. There is no undo. +::: + :::note -Article writes (create / update / delete) are supported. Collection & category writes and -article drafts are on the roadmap. The Docs API is rate-limited per 10-minute window; large -pulls back off automatically on `429`. +The Docs API is rate-limited per 10-minute window; large pulls back off automatically on `429`. ::: See the [command reference](/reference/commands/#docs) for every command and flag. diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index b98adc0..31abf49 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 80 commands in `hscli` v0.8.1. +This page lists all 88 commands in `hscli` v0.8.1. ## alias @@ -119,14 +119,22 @@ Manage your Help Scout Docs knowledge base — sites, collections, categories, a | --- | --- | --- | | `docs article create` | Create a Docs article | `--collection` `--name` `--text` `--status` `--slug` `--categories` `--keywords` | | `docs article delete ` | Delete a Docs article | `--yes` | +| `docs article delete-draft ` | Discard the draft of a Docs article (published text is kept) | `--yes` | | `docs article get ` | Get a Docs article by id or number | — | | `docs article list` | List articles in a Docs collection or category | `--collection` `--category` `--status` `--limit` | +| `docs article save-draft ` | Save a draft for a Docs article (does not publish) | `--text` | | `docs article search ` | Search Docs articles by keyword | `--collection` `--site` `--status` `--visibility` `--limit` | | `docs article update ` | Update a Docs article | `--name` `--text` `--status` `--slug` | | `docs auth` | Store your Help Scout Docs API key in the OS keychain (separate from Mailbox auth) | `--api-key` | +| `docs category create` | Create a Docs category within a collection | `--collection` `--name` `--visibility` `--order` | +| `docs category delete ` | Delete a Docs category | `--yes` | | `docs category list ` | List categories within a Docs collection | `--limit` | +| `docs category update ` | Update a Docs category | `--name` `--visibility` `--order` | +| `docs collection create` | Create a Docs collection | `--site` `--name` `--visibility` `--order` | +| `docs collection delete ` | Delete a Docs collection | `--yes` | | `docs collection get ` | Get a Docs collection by id or number | — | | `docs collection list` | List Docs collections | `--limit` `--site` `--visibility` | +| `docs collection update ` | Update a Docs collection | `--name` `--visibility` `--order` `--site` | | `docs site get ` | Get a Docs site by id | — | | `docs site list` | List Docs sites | `--limit` | From 08a4586e59452370120920bb49cfe2a0a29a449e Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 22:01:12 +0200 Subject: [PATCH 7/9] docs(website): surface Docs knowledge base across the site Docs was only documented in its own guide + the generated reference. Wire it into the cross-cutting surfaces so the feature is discoverable: - overview: add the knowledge base to the capability list and Where-to-next - authentication: document the separate per-user Docs API key (hscli docs auth / HSCLI_DOCS_API_KEY) and add it to the env-var table - home: include `docs` in the search hint --- .../content/docs/guides/authentication.mdx | 22 +++++++++++++++++++ website/src/content/docs/guides/overview.mdx | 7 +++--- website/src/pages/index.astro | 4 ++-- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/website/src/content/docs/guides/authentication.mdx b/website/src/content/docs/guides/authentication.mdx index 4a31fe3..7afb5f8 100644 --- a/website/src/content/docs/guides/authentication.mdx +++ b/website/src/content/docs/guides/authentication.mdx @@ -41,6 +41,7 @@ hscli conv list --status active --output json | --- | --- | --- | | `HSCLI_APP_ID` | OAuth app client ID (for `--client-credentials` / setup). | — | | `HSCLI_APP_SECRET` | OAuth app client secret. | — | +| `HSCLI_DOCS_API_KEY` | Help Scout **Docs API** key (separate product — see below). | — | | `HSCLI_PROFILE` | Named profile to use for multi-account setups. | `default` | | `NO_COLOR` | Disable ANSI colors (standard convention). | — | @@ -62,6 +63,27 @@ default jordan@acme.io ✓ valid staging bot@acme-test.io ✓ valid ``` +## Docs API key (separate product) + +Help Scout **Docs** (the knowledge base) is a different product from the Mailbox/Inbox, and +it authenticates with its own **per-user API key** — not your OAuth login. The `hscli docs *` +commands use that key exclusively; everything else uses OAuth. + +Store it once in your keychain, or pass it through the environment for CI: + +```bash frame="terminal" title="zsh — hscli" +$ hscli docs auth # prompts for the key, validates, stores it +✓ Docs API key stored for profile "default" + +# CI — no keychain needed +HSCLI_DOCS_API_KEY="$HELPSCOUT_DOCS_KEY" hscli docs site list +``` + +Find the key in the Docs web app under your profile → **Authentication → API Keys** (you +need the _"Docs: Create new, edit settings & Collections"_ permission). The key is stored +per profile, so multi-account setups keep their Docs keys separate too. See the +[Docs guide](/guides/docs/) for the full command surface. + ## Using with agents Because hscli is fully self-describing, an AI agent can discover the entire surface from diff --git a/website/src/content/docs/guides/overview.mdx b/website/src/content/docs/guides/overview.mdx index 28fb9e7..ff80e65 100644 --- a/website/src/content/docs/guides/overview.mdx +++ b/website/src/content/docs/guides/overview.mdx @@ -4,9 +4,9 @@ description: What hscli is, who it's for, and why it's built the way it is. --- **hscli** is a fast, scriptable command-line interface for [Help Scout](https://www.helpscout.com/). -It drives conversations, customers, reporting, and full-account backups from your -terminal — and it's designed so that **CI pipelines and AI agents** can use it just -as easily as a human can. +It drives conversations, customers, reporting, full-account backups, and your **Docs +knowledge base** from your terminal — and it's designed so that **CI pipelines and AI +agents** can use it just as easily as a human can. ## Why it exists @@ -34,5 +34,6 @@ hscli conv list --status active --output json --fields id,subject - [Installation](/guides/installation/) — get the `hscli` command on your machine. - [Authentication](/guides/authentication/) — connect your Help Scout account. +- [Docs (knowledge base)](/guides/docs/) — manage Docs sites, collections, and articles. - [Using with agents](/automation/agents/) — point Claude Code or Codex at hscli. - [Command reference](/reference/commands/) — every command, flag, and exit code. diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index f840d8a..4ab9b4d 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -257,7 +257,7 @@ const description = Esc
-

Try conv list, backup, or authentication

+

Try conv list, backup, docs, or authentication

@@ -291,7 +291,7 @@ const description = const input = modal?.querySelector('input'); const results = modal?.querySelector('.search-modal__results'); const hint = - '

Try conv list, backup, or authentication

'; + '

Try conv list, backup, docs, or authentication

'; let pf = null; let pfFailed = false; From 266f95c0584443e1390729e7794a67773559a762 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 22:25:23 +0200 Subject: [PATCH 8/9] feat(website): feature Docs on the home with an inbox-to-article recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a full-width featured recipe to the recipes band: an agent reads last week's inbox, spots the most-asked question, and publishes the answer to the Docs knowledge base — version-controlled and shipped from CI. Keeps the approved 3-up layout intact (the featured card spans the full row via grid-column 1/-1, copy left / terminal right, stacking under 760px). Verified light + dark. --- website/src/pages/index.astro | 16 ++++++++++++++++ website/src/styles/home.css | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/website/src/pages/index.astro b/website/src/pages/index.astro index 4ab9b4d..53fba70 100644 --- a/website/src/pages/index.astro +++ b/website/src/pages/index.astro @@ -210,6 +210,22 @@ const description =
  --output csv > may.csv
+
+
+ Docs +

Let an agent keep your help center current

+

Point a model at last week's inbox, let it spot the most-asked question, and publish the answer straight to your Docs knowledge base — version-controlled and shipped from CI.

+ Docs knowledge base → +
+
+
# nightly — turn last week's repeat questions into a Docs article
+
$ hscli conv list --tag question --since 7d --output json \
+
  | claude -p "draft a KB article for the top question" > article.html
+
$ hscli docs article create --collection "$KB" \
+
  --name "Resetting your password" --text @article.html --status published
+
article published · exit 0
+
+
diff --git a/website/src/styles/home.css b/website/src/styles/home.css index 92765e2..9dd8dcd 100644 --- a/website/src/styles/home.css +++ b/website/src/styles/home.css @@ -144,7 +144,18 @@ .recipe__code { margin-top: auto; background: var(--code-bg); border-top: 1px solid var(--code-border); padding: 14px 16px; font-family: var(--font-mono); font-size: 12px; line-height: 1.7; overflow-x: auto; color: var(--code-fg); } .recipe__code .row { white-space: pre; } +/* Featured recipe — spans the full grid, copy left / terminal right */ +.recipe--wide { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1.2fr; gap: 0; align-items: stretch; } +.recipe--wide .recipe__head { padding: 24px; display: flex; flex-direction: column; justify-content: center; } +.recipe--wide .recipe__code { margin: 0; border-top: 0; border-left: 1px solid var(--code-border); } +.recipe__link { align-self: flex-start; margin-top: 14px; font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--accent); text-decoration: none; } +.recipe__link:hover { color: var(--accent-hover); } + @media (max-width: 900px) { .recipe-grid { grid-template-columns: 1fr; } } +@media (max-width: 760px) { + .recipe--wide { grid-template-columns: 1fr; } + .recipe--wide .recipe__code { border-left: 0; border-top: 1px solid var(--code-border); } +} /* ============================================================ CTA strip From 8ba2ee52baee1e9c368a82bada0b4e6bdffcf7b8 Mon Sep 17 00:00:00 2001 From: Eric Rodriguez Date: Thu, 4 Jun 2026 22:29:43 +0200 Subject: [PATCH 9/9] chore(release): 0.9.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Help Scout Docs API support — the hscli docs command group (auth, sites, collections, categories, articles incl. drafts) with full CRUD, plus docs site coverage and a home-page recipe. Regenerate the reference (88 cmds). --- CHANGELOG.md | 12 ++++++++++++ docs/commands.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- website/src/content/docs/reference/commands.mdx | 2 +- 5 files changed, 17 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f075da..518f6ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.0] - 2026-06-04 + +### Added + +- **Help Scout Docs API support** — a new `hscli docs` command group for the knowledge base. Docs is a separate product and authenticates with its own per-user API key, independent of the Mailbox OAuth login: + - `docs auth` — validate and store the Docs API key in the OS keychain (or pass `HSCLI_DOCS_API_KEY` for CI). + - **Read & search:** `docs site list|get`, `docs collection list|get`, `docs category list`, `docs article list|get|search`. + - **Articles:** `docs article create|update|delete`, plus `docs article save-draft|delete-draft`. + - **Collections & categories:** `docs collection create|update|delete`, `docs category create|update|delete`. + - The Docs client is host-locked to `docsapi.helpscout.net` and shares hscli's retry/backoff, rate-limit handling, structured `--output table|json|yaml|csv`, and deterministic exit codes. +- Documentation: a new **Docs (knowledge base)** guide, separate-API-key coverage in the authentication guide, and a home-page recipe showing an agent turning inbox patterns into published articles. The generated command reference now spans all 88 commands. + ## [0.8.1] - 2026-06-04 ### Changed diff --git a/docs/commands.md b/docs/commands.md index 94eb173..06fa76c 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface. -Reference for `hscli` v0.8.1 (88 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. +Reference for `hscli` v0.9.0 (88 commands). Every command also accepts the global flags `--output table|json|yaml|csv`, `--jq`, `--fields`, `--profile`, `--no-color`, `--verbose`, `--no-retry`, and `--timeout`. ## Top-level diff --git a/package-lock.json b/package-lock.json index 3ffe061..add1455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "hscli", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hscli", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@inquirer/prompts": "8.5.2", diff --git a/package.json b/package.json index eb51e0f..afdab35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@wavyx/hscli", - "version": "0.8.1", + "version": "0.9.0", "publishConfig": { "access": "public" }, diff --git a/website/src/content/docs/reference/commands.mdx b/website/src/content/docs/reference/commands.mdx index 31abf49..1b3c8be 100644 --- a/website/src/content/docs/reference/commands.mdx +++ b/website/src/content/docs/reference/commands.mdx @@ -12,7 +12,7 @@ hscli [target] [flags] ``` Run `hscli --help` for the live, self-describing version of any command. -This page lists all 88 commands in `hscli` v0.8.1. +This page lists all 88 commands in `hscli` v0.9.0. ## alias