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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,25 @@ 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.1] - 2026-06-05

Stability pass (from an in-depth adversarial review).

### Fixed

- **Reliability:** pagination no longer loops forever on a non-numeric `pages` value (both API clients); the 429 backoff no longer busy-loops when `Retry-After` is non-numeric (e.g. an HTTP-date) — both now fall back to a sane delay / terminate.
- **MCP — safety & robustness:**
- Tool argument values can no longer be reinterpreted as CLI flags (argv injection): flags are passed as `--name=value` and positional args after a `--` separator.
- A signal-killed tool subprocess is reported as an error instead of silent success with partial output.
- Tool calls now have a timeout and an output-size cap, so a hung or runaway command can't hang or OOM the server.
- `mcp serve` forwards the active `--profile` to tool calls (previously they silently ran under the default account).
- Integer arguments accept JSON numbers (`{id: 123}`), not only strings.
- `auth login/logout/refresh/setup` and `docs auth` are no longer exposed as tools (they manage local credentials and can open a browser / bind a port on the host); `workflow run` is now flagged destructive; the inert `yes` input was removed from delete tools.
- **Homebrew:** `--jq` now works — the formula depends on `jq` and points node-jq at it (its bundled binary can't be downloaded in the Homebrew sandbox).
- **Reports:** `--output csv`/`table` now fail with a clear message (reports are nested JSON) instead of emitting nothing.
- **Docs API:** `--text @missing-file` now reports a clear error instead of a raw stack trace.
- **Keychain:** a write with no usable keychain (e.g. in a container) gives the friendly "keychain unavailable" guidance instead of a raw `PermissionDenied`.

## [0.11.0] - 2026-06-05

### Added
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.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`.
Reference for `hscli` v0.11.1 (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.11.0",
"version": "0.11.1",
"publishConfig": {
"access": "public"
},
Expand Down
10 changes: 9 additions & 1 deletion scripts/gen-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,23 @@ export function renderHomebrewFormula({ url, sha256 }) {
sha256 "${sha256}"
license "MIT"

depends_on "jq"
depends_on "node"

def install
system "npm", "install", *std_npm_args
bin.install_symlink Dir["#{libexec}/bin/*"]
# hscli's --jq flag uses node-jq, which normally downloads its own jq binary
# via a postinstall script. std_npm_args passes --ignore-scripts and the
# Homebrew build sandbox blocks network, so point node-jq at the Homebrew jq
# instead (node-jq honors $JQ_PATH at runtime).
(bin/"hscli").write_env_script libexec/"bin/hscli",
JQ_PATH: Formula["jq"].opt_bin/"jq"
end

test do
assert_match "hscli", shell_output("#{bin}/hscli version")
# Exercise --jq so the node-jq / Homebrew-jq wiring can't silently regress.
system bin/"hscli", "config", "list", "--output", "json", "--jq", "."
end
end
`
Expand Down
4 changes: 3 additions & 1 deletion src/commands/mcp/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ export default class MCPServeCommand extends BaseCommand {
async run() {
const { flags } = await this.parse(MCPServeCommand)
// Each tool call re-invokes this same CLI as a child process, keeping the
// parent's stdout (the MCP stdio channel) free of command output.
// parent's stdout (the MCP stdio channel) free of command output. Forward
// the active profile so tools run under the same account as the server.
const exec = makeExec({
command: process.execPath,
args: [process.argv[1]],
env: { HSCLI_PROFILE: this.activeProfile },
})
await startMcpServer({
config: this.config,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/report/company.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Flags } from '@oclif/core'
import BaseCommand from '../../base-command.js'
import { assertReportFormat } from '../../lib/report-format.js'

export default class ReportCompanyCommand extends BaseCommand {
static description = 'Get company report'
Expand All @@ -23,6 +24,7 @@ export default class ReportCompanyCommand extends BaseCommand {
async run() {
const { flags } = await this.parse(ReportCompanyCommand)
this.flags.output = this.flags.output || 'json'
assertReportFormat(this.flags.output)

const query = {
start: flags.start,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/report/conversations.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Flags } from '@oclif/core'
import BaseCommand from '../../base-command.js'
import { assertReportFormat } from '../../lib/report-format.js'

export default class ReportConversationsCommand extends BaseCommand {
static description = 'Get conversations report'
Expand All @@ -23,6 +24,7 @@ export default class ReportConversationsCommand extends BaseCommand {
async run() {
const { flags } = await this.parse(ReportConversationsCommand)
this.flags.output = this.flags.output || 'json'
assertReportFormat(this.flags.output)

const query = {
start: flags.start,
Expand Down
2 changes: 2 additions & 0 deletions src/commands/report/user.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Flags } from '@oclif/core'
import BaseCommand from '../../base-command.js'
import { assertReportFormat } from '../../lib/report-format.js'

export default class ReportUserCommand extends BaseCommand {
static description = 'Get user report'
Expand All @@ -21,6 +22,7 @@ export default class ReportUserCommand extends BaseCommand {
async run() {
const { flags } = await this.parse(ReportUserCommand)
this.flags.output = this.flags.output || 'json'
assertReportFormat(this.flags.output)

const query = {
start: flags.start,
Expand Down
9 changes: 7 additions & 2 deletions src/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ export function createClient({
debug('%s %s → %d', method, path, res.status)

if (res.status === 429) {
const wait = Number(res.headers.get('x-ratelimit-retry-after') || 10)
const raw = res.headers.get('x-ratelimit-retry-after')
const parsed = raw == null ? NaN : Number(raw)
const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 10
if (!retry) throw new RateLimitError(wait)
debug('rate limited, waiting %ds', wait)
await sleep(wait * 1000)
Expand Down Expand Up @@ -124,7 +126,10 @@ export function createClient({
query: { ...query, page },
})
const items = data?._embedded?.[resourceKey] ?? []
const totalPages = data?.page?.totalPages ?? 1
// Guard against a non-numeric/missing total so we never loop forever.
const rawPages = Number(data?.page?.totalPages)
const totalPages =
Number.isFinite(rawPages) && rawPages >= 1 ? rawPages : page
if (opts.onProgress) opts.onProgress({ page, totalPages })
yield* items
if (page >= totalPages) break
Expand Down
14 changes: 8 additions & 6 deletions src/lib/docs-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,10 @@ export function createDocsClient({
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,
)
const raw =
res.headers.get('x-ratelimit-reset') || res.headers.get('retry-after')
const parsed = raw == null ? NaN : Number(raw)
const wait = Number.isFinite(parsed) && parsed >= 0 ? parsed : 10
if (!retry) throw new RateLimitError(wait)
debug('rate limited, waiting %ds', wait)
await sleep(wait * 1000)
Expand Down Expand Up @@ -116,7 +115,10 @@ export function createDocsClient({
const data = await request('GET', path, { query: { ...query, page } })
const wrap = data?.[resourceKey] ?? {}
const items = wrap.items ?? []
const totalPages = wrap.pages ?? 1
// Guard against a non-numeric/missing `pages` so we never loop forever.
const rawPages = Number(wrap.pages)
const totalPages =
Number.isFinite(rawPages) && rawPages >= 1 ? rawPages : page
if (opts.onProgress) opts.onProgress({ page, totalPages })
yield* items
if (page >= totalPages) break
Expand Down
10 changes: 9 additions & 1 deletion src/lib/docs-input.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { readFileSync } from 'node:fs'
import { CliError } from './errors.js'

/**
* Resolve article text: a leading `@` reads the rest as a file path; otherwise
Expand All @@ -8,7 +9,14 @@ import { readFileSync } from 'node:fs'
*/
export function readText(value) {
if (value && value.startsWith('@')) {
return readFileSync(value.slice(1), 'utf8')
const path = value.slice(1)
try {
return readFileSync(path, 'utf8')
} catch (err) {
throw new CliError(`Cannot read --text file '${path}': ${err.message}`, {
exitCode: 66,
})
}
}
return value
}
Expand Down
15 changes: 13 additions & 2 deletions src/lib/keychain.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export async function getTokens(profile) {
export async function setTokens(profile, tokens) {
if (!Entry) keychainRequired()
const account = `${profile}/tokens`
getEntry(account).setPassword(JSON.stringify(tokens))
try {
getEntry(account).setPassword(JSON.stringify(tokens))
} catch (err) {
// e.g. PermissionDenied in a container with no Secret Service.
debug('setTokens error: %s', err.message)
keychainRequired()
}
}

/** @param {string} profile */
Expand Down Expand Up @@ -91,7 +97,12 @@ export async function getDocsKey(profile) {
*/
export async function setDocsKey(profile, apiKey) {
if (!Entry) keychainRequired()
getEntry(`${profile}/docs-key`).setPassword(apiKey)
try {
getEntry(`${profile}/docs-key`).setPassword(apiKey)
} catch (err) {
debug('setDocsKey error: %s', err.message)
keychainRequired()
}
}

/** @param {string} profile */
Expand Down
24 changes: 22 additions & 2 deletions src/lib/mcp/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,23 @@
// - `conv:watch` is a long-running stream that doesn't fit request/response.
// - `doctor` is a local-environment diagnostic (live network probe), not useful to an agent.
// - `mcp:serve` is this server itself — exposing it would let a tool spawn another server.
export const EXCLUDED = new Set(['api', 'conv:watch', 'doctor', 'mcp:serve'])
// - `auth:*` / `docs:auth` manage the operator's LOCAL credentials (login opens a
// browser + binds a port on the host); they make no sense as agent tools.
export const EXCLUDED = new Set([
'api',
'conv:watch',
'doctor',
'mcp:serve',
'auth:login',
'auth:logout',
'auth:refresh',
'auth:setup',
'docs:auth',
])

// Commands that mutate broadly and must carry the destructive hint even though
// their leaf verb isn't delete/remove/bulk.
const DESTRUCTIVE_IDS = new Set(['workflow:run'])

// Topics whose every command is read-only.
const READ_TOPICS = new Set(['report', 'beacon'])
Expand Down Expand Up @@ -45,7 +61,11 @@ export function classifyKind(id) {
const leaf = id.split(':').pop()
// delete/remove and bulk operations hit data destructively — flag them so MCP
// clients prompt before running them.
if (/^(delete|remove)(-|$)/.test(leaf) || leaf.startsWith('bulk')) {
if (
DESTRUCTIVE_IDS.has(id) ||
/^(delete|remove)(-|$)/.test(leaf) ||
leaf.startsWith('bulk')
) {
return 'destructive'
}
if (WRITE_OVERRIDE.has(id)) return 'write'
Expand Down
Loading
Loading