Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# The image installs hscli from npm, so the build needs no repo files.
*
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
permissions:
contents: write # create the GitHub Release
id-token: write # OIDC: npm trusted publishing + provenance (no NPM_TOKEN)
packages: write # push the Docker image to GHCR

jobs:
release:
Expand Down Expand Up @@ -61,3 +62,29 @@ jobs:
gh release create "$GITHUB_REF_NAME" \
--title "$GITHUB_REF_NAME" \
--notes "${NOTES:-Release $GITHUB_REF_NAME}"

docker:
name: Publish Docker image
needs: release # build from the just-published npm version
runs-on: ubuntu-latest
steps:
- name: Resolve version
id: ver
run: echo "v=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"

- uses: docker/setup-buildx-action@v3

- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: HSCLI_VERSION=${{ steps.ver.outputs.v }}
tags: |
ghcr.io/${{ github.repository }}:${{ steps.ver.outputs.v }}
ghcr.io/${{ github.repository }}:latest
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ docs/superpowers/
website/node_modules/
website/dist/
website/.astro/

# Generated dist artifacts (live in the tap/bucket repos)
packaging/homebrew/hscli.rb
packaging/scoop/hscli.json
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ package-lock.json
website/
# generated by scripts/gen-commands.mjs
docs/commands.md

# Dockerfiles (no prettier parser)
Dockerfile
.dockerignore
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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.11.0] - 2026-06-05

### Added

- **Distribution beyond npm:**
- **Docker** — `docker run --rm ghcr.io/wavyx/hscli --help`. Built and pushed to GHCR by the release workflow.
- **Homebrew** — `brew tap wavyx/tap && brew install hscli` (macOS/Linux).
- **Scoop** — `scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli` (Windows).
- npm stays the source of truth; `scripts/gen-dist.mjs` regenerates the Homebrew formula + Scoop manifest from the published tarball (with its sha256).
- `auth status --output json` emits a structured status object (profile, keychain, token state, user) — handy for scripts and as a structured MCP tool result.

### Changed

- MCP: `mcp serve` no longer advertises the output-shaping global flags (`--output`/`--jq`/`--fields`/`--timeout`/`--no-retry`/`--verbose`/`--no-color`) it never uses; it keeps `--profile` and `--allow-writes`.

## [0.10.2] - 2026-06-05

### Added
Expand Down
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Run hscli without a local Node install:
# docker run --rm -e HSCLI_DOCS_API_KEY ghcr.io/wavyx/hscli docs site list
#
# The image installs the published npm package, so build it after publishing
# (the release workflow passes the released version via HSCLI_VERSION).
FROM node:20-slim

ARG HSCLI_VERSION=latest

LABEL org.opencontainers.image.source="https://github.com/wavyx/hscli"
LABEL org.opencontainers.image.description="Command-line interface for Help Scout"
LABEL org.opencontainers.image.licenses="MIT"

RUN npm install -g "@wavyx/hscli@${HSCLI_VERSION}" \
&& npm cache clean --force

ENTRYPOINT ["hscli"]
CMD ["--help"]
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ Codex, and similar) — no SDK glue required.
## Install

```bash
npm install -g @wavyx/hscli
npm install -g @wavyx/hscli # npm (Node.js 20+)
brew tap wavyx/tap && brew install hscli # Homebrew (macOS/Linux)
scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli # Scoop (Windows)
docker run --rm ghcr.io/wavyx/hscli --help # Docker (no local Node)
```

Requires Node.js 20+. The binary is `hscli`.
The binary is `hscli`. The Docker image has no OS keychain, so use it for Docs
(`HSCLI_DOCS_API_KEY`), `api`, and stateless utilities rather than Mailbox OAuth.

> **Credential storage:** hscli stores OAuth tokens only in your operating system
> keychain (macOS Keychain, Windows Credential Manager, or libsecret on Linux).
Expand Down
2 changes: 1 addition & 1 deletion docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Full command reference for the hscli command-line interface.

<!-- AUTO-GENERATED from the oclif manifest by scripts/gen-commands.mjs — do not edit by hand. -->

Reference for `hscli` v0.10.2 (89 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.11.0 (89 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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@wavyx/hscli",
"version": "0.10.2",
"version": "0.11.0",
"publishConfig": {
"access": "public"
},
Expand Down
30 changes: 30 additions & 0 deletions packaging/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Packaging

hscli ships through several channels. npm is the source of truth; the others
are generated from the published tarball.

| Channel | Install | Source |
| -------- | ------------------------------------------------------------------------------------ | ------------------------------ |
| npm | `npm install -g @wavyx/hscli` | this repo (`npm publish`) |
| Docker | `docker run --rm ghcr.io/wavyx/hscli --help` | `Dockerfile` (release.yml) |
| Homebrew | `brew tap wavyx/tap && brew install hscli` | `wavyx/homebrew-tap` (formula) |
| Scoop | `scoop bucket add hscli https://github.com/wavyx/scoop-hscli && scoop install hscli` | `wavyx/scoop-hscli` (manifest) |

## Updating Homebrew + Scoop on release

After a version is published to npm:

```bash
node scripts/gen-dist.mjs <version> # e.g. 0.11.0
```

This downloads the npm tarball, computes its sha256, and writes:

- `packaging/homebrew/hscli.rb` → commit to `wavyx/homebrew-tap` as `Formula/hscli.rb`
- `packaging/scoop/hscli.json` → commit to `wavyx/scoop-hscli` as `bucket/hscli.json`

Both generated files are git-ignored here — they live in their own repos.

> Auto-bumping these on every release would need a cross-repo token (PAT); for
> now it's a one-line manual step. The Docker image and npm publish are fully
> automated in `.github/workflows/release.yml`.
96 changes: 96 additions & 0 deletions scripts/gen-dist.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Regenerate the Homebrew formula + Scoop manifest for a published version.
//
// node scripts/gen-dist.mjs <version>
//
// Writes packaging/homebrew/hscli.rb and packaging/scoop/hscli.json (both
// git-ignored — they live in the wavyx/homebrew-tap and wavyx/scoop-hscli
// repos). See packaging/README.md. Run AFTER the version is on npm.
import { writeFileSync, mkdirSync } from 'node:fs'
import { createHash } from 'node:crypto'

const PKG = '@wavyx/hscli'

/** Render the Homebrew formula (standard node-CLI pattern). */
export function renderHomebrewFormula({ version, url, sha256 }) {
return `class Hscli < Formula
desc "Command-line interface for Help Scout"
homepage "https://github.com/wavyx/hscli"
url "${url}"
sha256 "${sha256}"
version "${version}"
license "MIT"

depends_on "node"

def install
system "npm", "install", *Language::Node.std_npm_install_args(libexec)
bin.install_symlink Dir["#{libexec}/bin/*"]
end

test do
assert_match "hscli", shell_output("#{bin}/hscli version")
end
end
`
}

/** Render the Scoop manifest (installs via npm; needs Node). */
export function renderScoopManifest({ version }) {
return (
JSON.stringify(
{
version,
description: 'Command-line interface for Help Scout',
homepage: 'https://github.com/wavyx/hscli',
license: 'MIT',
depends: 'nodejs',
installer: { script: [`npm install -g ${PKG}@${version}`] },
uninstaller: { script: [`npm uninstall -g ${PKG}`] },
checkver: {
url: `https://registry.npmjs.org/${PKG}`,
jsonpath: "$.['dist-tags'].latest",
},
autoupdate: { version: '$version' },
},
null,
2,
) + '\n'
)
}

/** Fetch the npm tarball URL + its sha256 for a version. */
export async function fetchDist(version, fetchFn = fetch) {
const meta = await fetchFn(
`https://registry.npmjs.org/${PKG}/${version}`,
).then((r) => r.json())
const url = meta.dist.tarball
const buf = Buffer.from(await fetchFn(url).then((r) => r.arrayBuffer()))
const sha256 = createHash('sha256').update(buf).digest('hex')
return { version, url, sha256 }
}

if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) {
const version = process.argv[2]
if (!version) {
console.error('usage: node scripts/gen-dist.mjs <version>')
process.exit(1)
}
const dist = await fetchDist(version)
mkdirSync(new URL('../packaging/homebrew/', import.meta.url), {
recursive: true,
})
mkdirSync(new URL('../packaging/scoop/', import.meta.url), {
recursive: true,
})
writeFileSync(
new URL('../packaging/homebrew/hscli.rb', import.meta.url),
renderHomebrewFormula(dist),
)
writeFileSync(
new URL('../packaging/scoop/hscli.json', import.meta.url),
renderScoopManifest(dist),
)
console.log(
`Wrote packaging/homebrew/hscli.rb + packaging/scoop/hscli.json for ${version} (sha256 ${dist.sha256})`,
)
}
85 changes: 51 additions & 34 deletions src/commands/auth/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,42 @@ export default class StatusCommand extends BaseCommand {
}

async run() {
await this.parse(StatusCommand)
const { flags } = await this.parse(StatusCommand)

const tokens = await getTokens(this.activeProfile)
const keychainType = isKeychainAvailable() ? 'OS keychain' : 'unavailable'
const status = {
profile: this.activeProfile,
keychain: isKeychainAvailable() ? 'OS keychain' : 'unavailable',
authenticated: Boolean(tokens),
}

if (tokens) {
status.authMode = tokens.authMode
status.credentialSource = tokens.credentialSource
const now = Date.now()
const expiresAt = tokens.expiresAt
if (expiresAt <= now) {
status.token = { state: 'expired', expiresAt }
} else {
status.token = {
state: 'valid',
expiresAt,
expiresInMs: expiresAt - now,
}
const user = await this.#fetchUser(tokens.accessToken)
if (user) status.user = user
}
}

if (flags.output === 'json') {
this.log(JSON.stringify(status, null, 2))
return
}

this.log(chalk.bold('Auth Status'))
this.log('')
this.log(` Profile: ${chalk.cyan(this.activeProfile)}`)
this.log(` Keychain: ${keychainType}`)
this.log(` Profile: ${chalk.cyan(status.profile)}`)
this.log(` Keychain: ${status.keychain}`)

if (!tokens) {
this.log(` Status: ${chalk.red('Not authenticated')}`)
Expand All @@ -31,55 +58,45 @@ export default class StatusCommand extends BaseCommand {
return
}

this.log(` Auth mode: ${tokens.authMode}`)
this.log(` Credential: ${tokens.credentialSource}`)
this.log(` Auth mode: ${status.authMode}`)
this.log(` Credential: ${status.credentialSource}`)

const now = Date.now()
const expiresAt = tokens.expiresAt

if (expiresAt <= now) {
if (status.token.state === 'expired') {
this.log(` Token: ${chalk.red('Expired')}`)
} else {
const remaining = expiresAt - now
const humanExpiry = formatDuration(remaining)
this.log(
` Token: ${chalk.green('Valid')} (expires in ${humanExpiry})`,
` Token: ${chalk.green('Valid')} (expires in ${formatDuration(status.token.expiresInMs)})`,
)
}

// Try fetching user info if token is still valid
if (expiresAt > now) {
await this.#showUserInfo(tokens.accessToken)
if (status.user) {
this.log('')
this.log(chalk.bold(' Authenticated User'))
if (status.user.name) this.log(` Name: ${status.user.name}`)
if (status.user.email) this.log(` Email: ${status.user.email}`)
}
}

/**
* Fetch and display the authenticated user's identity.
* Fetch the authenticated user's identity (best-effort).
* @param {string} accessToken
* @returns {Promise<{name?: string, email?: string} | null>}
*/
async #showUserInfo(accessToken) {
async #fetchUser(accessToken) {
try {
const res = await fetch('https://api.helpscout.net/v2/users/me', {
headers: { authorization: `Bearer ${accessToken}` },
})

if (!res.ok) return

if (!res.ok) return null
const data = await res.json()
this.log('')
this.log(chalk.bold(' Authenticated User'))

if (data.firstName || data.lastName) {
this.log(
` Name: ${[data.firstName, data.lastName].filter(Boolean).join(' ')}`,
)
}

if (data.email) {
this.log(` Email: ${data.email}`)
}
const user = {}
const name = [data.firstName, data.lastName].filter(Boolean).join(' ')
if (name) user.name = name
if (data.email) user.email = data.email
return user
} catch {
// Silently ignore network errors — user info is best-effort
// Network errors are non-fatal — user info is best-effort.
return null
}
}
}
Expand Down
Loading
Loading