Skip to content
Draft
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
87 changes: 84 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
[![License](https://img.shields.io/npm/l/@hackmd/hackmd-cli.svg)](https://github.com/hackmdio/hackmd-cli/blob/master/package.json)

* [Usage](#usage)
* [Full API access](#full-api-access)
* [Commands](#commands)
* [Configuration](#configuration)
* [License](#license)
Expand All @@ -31,14 +32,31 @@ $ npm install -g @hackmd/hackmd-cli
$ hackmd-cli COMMAND
running command...
$ hackmd-cli (--version|-v)
@hackmd/hackmd-cli/2.5.1 darwin-arm64 node-v26.0.0
@hackmd/hackmd-cli/2.5.1 darwin-arm64 node-v24.16.0
$ hackmd-cli --help [COMMAND]
USAGE
$ hackmd-cli COMMAND
...
```
<!-- usagestop -->

## Full API access

The existing `notes`, `folders`, and other commands remain unchanged. For operations without a dedicated command, use the installed API client's generated operations:

```bash
hackmd-cli api operations
hackmd-cli api describe GetTeamNote
hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc
hackmd-cli api call ListVersions --path noteId=abc --query limit=10
hackmd-cli api call CreateNote --body @note.json
hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png
```

Use repeated `--path key=value`, `--query key=value`, and `--header 'Name: value'` flags. `--body` accepts JSON text, `@file`, or `-` for stdin. For multipart image uploads, use `--file image=@path`; the file extension sets its MIME type, or use `--mime` to override it. `--include` prints HTTP status and headers before the response body. Empty 204/304 responses print no body; NDJSON is printed unchanged. Errors exit nonzero.

`api operations` and `api describe` work offline and show what the **installed API client version** supports, not what a connected HackMD EE server necessarily supports. `api call` uses the same access token and endpoint configuration as other commands. Writes are not retried automatically.

## Configuration

### Set access token
Expand Down Expand Up @@ -100,6 +118,9 @@ All available configurations are listed in the table below.
## Commands

<!-- commands -->
* [`hackmd-cli api call OPERATIONID`](#hackmd-cli-api-call-operationid)
* [`hackmd-cli api describe OPERATIONID`](#hackmd-cli-api-describe-operationid)
* [`hackmd-cli api operations`](#hackmd-cli-api-operations)
* [`hackmd-cli autocomplete [SHELL]`](#hackmd-cli-autocomplete-shell)
* [`hackmd-cli export`](#hackmd-cli-export)
* [`hackmd-cli folders`](#hackmd-cli-folders)
Expand Down Expand Up @@ -128,6 +149,66 @@ All available configurations are listed in the table below.
* [`hackmd-cli version`](#hackmd-cli-version)
* [`hackmd-cli whoami`](#hackmd-cli-whoami)

## `hackmd-cli api call OPERATIONID`

Call an operation through the installed @hackmd/api/raw API client

```
USAGE
$ hackmd-cli api call OPERATIONID [--body <value>] [--file <value>] [--header <value>] [-h] [--include]
[--mime <value>] [--path <value>] [--query <value>]

FLAGS
-h, --help Show CLI help.
--body=<value> JSON value, @file, or - for stdin
--file=<value>... Multipart binary field, e.g. image=@photo.png
--header=<value>... Request header Name: value
--include Include HTTP status and response headers
--mime=<value> MIME type override for --file
--path=<value>... Path parameter key=value
--query=<value>... Query parameter key=value

DESCRIPTION
Call an operation through the installed @hackmd/api/raw API client

EXAMPLES
$ hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc

$ hackmd-cli api call CreateNote --body @note.json

$ hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png
```

## `hackmd-cli api describe OPERATIONID`

Describe one installed API client operation (offline)

```
USAGE
$ hackmd-cli api describe OPERATIONID [-h]

FLAGS
-h, --help Show CLI help.

DESCRIPTION
Describe one installed API client operation (offline)
```

## `hackmd-cli api operations`

List operations supported by the installed @hackmd/api API client (offline)

```
USAGE
$ hackmd-cli api operations [-h]

FLAGS
-h, --help Show CLI help.

DESCRIPTION
List operations supported by the installed @hackmd/api API client (offline)
```

## `hackmd-cli autocomplete [SHELL]`

display autocomplete installation instructions
Expand Down Expand Up @@ -550,7 +631,7 @@ EXAMPLES

$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2

cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA
$ cat README.md | hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA
```

## `hackmd-cli team-folders`
Expand Down Expand Up @@ -836,7 +917,7 @@ EXAMPLES

$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --tags=tag1,tag2

cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA
$ cat README.md | hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA
```

## `hackmd-cli teams`
Expand Down
Binary file modified hackmd-cli.skill
Binary file not shown.
16 changes: 15 additions & 1 deletion hackmd-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: hackmd-cli
description: HackMD command-line interface for managing personal/team notes and folders. Use this skill when users want to create, read, update, delete, reorder, or export HackMD notes and folders via CLI, manage team content, list teams, view browsing history, or automate HackMD workflows.
description: HackMD command-line interface for managing notes and folders or calling any v1 API operation through the installed API client. Use for HackMD content, team workflows, exports, or API automation.
---

# HackMD CLI
Expand Down Expand Up @@ -35,6 +35,20 @@ export HMD_API_ENDPOINT_URL=https://your.hackmd-ee.endpoint

## Commands

### Full v1 API via the installed API client

Prefer the existing `notes`, `folders`, and other focused commands when available. For any other v1 operation, discover the exact operation ID and parameters offline, then call it through the API client's raw layer:

```bash
hackmd-cli api operations
hackmd-cli api describe GetTeamNote
hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc
hackmd-cli api call CreateNote --body @note.json
hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png
```

Repeat `--path key=value`, `--query key=value`, or `--header 'Name: value'` as needed. `--body` accepts JSON text, `@file`, or `-` for stdin. `--file image=@path` is for multipart upload; use `--mime` if the extension is unknown. `--include` shows HTTP status and headers. The operation list describes the installed API client, not necessarily an older EE server; check server support before using a new operation. Generic writes are not retried automatically. Do not run writes or deletes without the user's authorization.

### Authentication

```bash
Expand Down
68 changes: 68 additions & 0 deletions src/api/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type {OperationId} from '@hackmd/api/raw'

Check failure on line 1 in src/api/operations.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

Cannot find module '@hackmd/api/raw' or its corresponding type declarations.

import {operationRegistry} from '@hackmd/api/raw'

Check failure on line 3 in src/api/operations.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

Cannot find module '@hackmd/api/raw' or its corresponding type declarations.

export type Operation = {
call: unknown;
method: string;
parameters: ReadonlyArray<{in: string; name: string; required: boolean; type: string}>;
path: string;
requestBody?: {
binaryFields: ReadonlyArray<{name: string; required: boolean}>;
contentTypes: readonly string[];
required: boolean;
};
responses: Record<string, readonly string[]>;
}

export function getOperation(id: string): Operation {
if (!Object.hasOwn(operationRegistry, id)) {
throw new Error(`Unknown operation "${id}". Run "hackmd-cli api operations" to list the installed API client's operations.`)
}

return operationRegistry[id as OperationId]
}

export function parsePairs(values: string[], separator: string, kind: string): Record<string, string> {
const result: Record<string, string> = Object.create(null)
for (const value of values) {
const index = value.indexOf(separator)
if (index <= 0) throw new Error(`Invalid --${kind} value "${value}"; expected key${separator}value`)
const key = value.slice(0, index).trim()
if (!key || Object.hasOwn(result, key)) throw new Error(`Duplicate or empty --${kind} key "${key}"`)
result[key] = value.slice(index + separator.length).trim()
}

return result
}

export function validateParameters(operation: Operation, location: 'path' | 'query', input: Record<string, string>): Record<string, boolean | number | string> {
const parameters = operation.parameters.filter(parameter => parameter.in === location)
const allowed = new Map(parameters.map(parameter => [parameter.name, parameter]))
for (const parameter of parameters) {
if (parameter.required && !Object.hasOwn(input, parameter.name)) {
throw new Error(`Missing required --${location} ${parameter.name}=...`)
}
}

const result: Record<string, boolean | number | string> = {}
for (const [key, value] of Object.entries(input)) {
const parameter = allowed.get(key)
if (!parameter) throw new Error(`Unknown --${location} parameter "${key}" for ${operation.method} ${operation.path}`)
if (parameter.type === 'number' || parameter.type === 'integer') {
const number = Number(value)
if (value === '' || !Number.isFinite(number) || (parameter.type === 'integer' && !Number.isInteger(number))) {
throw new Error(`--${location} ${key} must be a ${parameter.type}`)
}

result[key] = number
} else if (parameter.type === 'boolean') {
if (value !== 'true' && value !== 'false') throw new Error(`--${location} ${key} must be true or false`)
result[key] = value === 'true'
} else {
result[key] = value
}
}

return result
}
154 changes: 154 additions & 0 deletions src/commands/api/call.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type {Client} from '@hackmd/api/raw'

Check failure on line 1 in src/commands/api/call.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

Cannot find module '@hackmd/api/raw' or its corresponding type declarations.

import {createClient} from '@hackmd/api/raw'

Check failure on line 3 in src/commands/api/call.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

Cannot find module '@hackmd/api/raw' or its corresponding type declarations.
import {Args, Flags, ux} from '@oclif/core'
import {readFileSync} from 'node:fs'
import {basename, extname} from 'node:path'

import type {Operation} from '../../api/operations'

import {getOperation, parsePairs, validateParameters} from '../../api/operations'
import HackMDCommand from '../../command'
import config from '../../config'
import {setAccessTokenConfig} from '../../utils'

type RawResponse = {data: unknown; headers: Record<string, unknown>; status: number}
type RawCall = (options: {
body?: unknown;
client: Client;
headers?: Record<string, string>;
path?: Record<string, boolean | number | string>;
query?: Record<string, boolean | number | string>;
responseType?: 'text';
throwOnError: true;
}) => Promise<RawResponse>

const mimeTypes: Record<string, string> = {
'.avif': 'image/avif',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.webp': 'image/webp',
}

function readBody(value: string): unknown {
const content = value === '-'
? readFileSync(process.stdin.fd, 'utf8')
: (value.startsWith('@') ? readFileSync(value.slice(1), 'utf8') : value)
try {
return JSON.parse(content)
} catch {
throw new Error('--body must contain valid JSON')
}
}

function prepareFiles(operation: Operation, files: string[], mime: string | undefined): Record<string, File> {
if (!operation.requestBody?.contentTypes.includes('multipart/form-data')) throw new Error('--file is only supported for multipart operations')
const result: Record<string, File> = {}
for (const input of files) {
const [field, filepath] = input.split('=@', 2)
if (!field || !filepath || !operation.requestBody.binaryFields.some(entry => entry.name === field)) {
throw new Error(`Invalid --file "${input}"; expected a documented field such as image=@path`)
}

if (result[field]) throw new Error(`Duplicate --file field "${field}"`)
const contentType = mime ?? mimeTypes[extname(filepath).toLowerCase()]
if (!contentType) throw new Error(`Cannot infer MIME type for ${filepath}; pass --mime`)
result[field] = new File([readFileSync(filepath)], basename(filepath), {type: contentType})
}

for (const field of operation.requestBody.binaryFields) {
if (field.required && !result[field.name]) throw new Error(`Missing required --file ${field.name}=@path`)
}

return result
}

function prepareBody(operation: Operation, body: string | undefined, file: string[] | undefined, mime: string | undefined): unknown {
const files = file ?? []
if (body !== undefined && files.length > 0) throw new Error('Use either --body or --file, not both')
if (mime && files.length === 0) throw new Error('--mime requires --file')
if ((body !== undefined || files.length > 0) && !operation.requestBody) throw new Error(`${operation.method} ${operation.path} has no request body`)
if (operation.requestBody?.required && body === undefined && files.length === 0) throw new Error('This operation requires --body or --file')
if (files.length > 0) return prepareFiles(operation, files, mime)

if (body !== undefined) {
if (!operation.requestBody?.contentTypes.includes('application/json')) throw new Error('--body JSON is not supported by this operation')
return readBody(body)
}
}

function formatBody(data: unknown): string {
if (typeof data === 'string') return data
return JSON.stringify(data, null, 2)
}

export default class CallCommand extends HackMDCommand {
static args = {operationId: Args.string({required: true})}
static description = 'Call an operation through the installed @hackmd/api/raw API client'
static examples = [
'hackmd-cli api call GetTeamNote --path teampath=docs --path noteId=abc',
'hackmd-cli api call CreateNote --body @note.json',
'hackmd-cli api call UploadNoteImage --path noteId=abc --file image=@photo.png',
]
static flags = {
body: Flags.string({description: 'JSON value, @file, or - for stdin'}),
file: Flags.string({description: 'Multipart binary field, e.g. image=@photo.png', multiple: true}),
header: Flags.string({description: 'Request header Name: value', multiple: true}),
help: Flags.help({char: 'h'}),
include: Flags.boolean({description: 'Include HTTP status and response headers'}),
mime: Flags.string({description: 'MIME type override for --file'}),
path: Flags.string({description: 'Path parameter key=value', multiple: true}),
query: Flags.string({description: 'Query parameter key=value', multiple: true}),
}

async run() {
const {args, flags} = await this.parse(CallCommand)
const operation = getOperation(args.operationId)
const path = validateParameters(operation, 'path', parsePairs(flags.path ?? [], '=', 'path'))
const query = validateParameters(operation, 'query', parsePairs(flags.query ?? [], '=', 'query'))
const headers = parsePairs(flags.header ?? [], ':', 'header')
const body = prepareBody(operation, flags.body, flags.file, flags.mime)
const token = config.accessToken || await ux.prompt('Enter your access token', {type: 'hide'})
if (!token) throw new Error('An access token is required')
const client = createClient({
auth: token,
baseURL: config.hackmdAPIEndpointURL,
validateStatus: status => (status >= 200 && status < 300) || status === 304,

Check failure on line 119 in src/commands/api/call.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

Parameter 'status' implicitly has an 'any' type.
})
const ndjson = Object.values(operation.responses).some(contentTypes => contentTypes.includes('application/x-ndjson'))
try {
const response = await (operation.call as unknown as RawCall)({
body,
client,
headers,
path,
query,
...(ndjson ? {responseType: 'text' as const} : {}),
throwOnError: true,
})
if (!config.accessToken) setAccessTokenConfig(token)
if (flags.include) {
this.log(`HTTP ${response.status}`)
for (const [name, value] of Object.entries(response.headers)) this.log(`${name}: ${value}`)
this.log('')
}

const hasBody = operation.responses[String(response.status)]?.length !== 0
if (hasBody && response.data !== undefined && response.data !== null) {
if (ndjson) process.stdout.write(formatBody(response.data))
else this.log(formatBody(response.data))
}
} catch (error) {
const failure = error as {message?: string; response?: RawResponse}
if (failure.response) {
const {data, status} = failure.response
this.error(`HTTP ${status}${data === undefined ? '' : `: ${formatBody(data)}`}`)
}

this.error(failure.message ?? String(error))
}
}
}
Loading
Loading