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
167 changes: 144 additions & 23 deletions README.md

Large diffs are not rendered by default.

Binary file modified hackmd-cli.skill
Binary file not shown.
41 changes: 37 additions & 4 deletions 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, folders, and other v1 API operations. 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

### Other API operations

Prefer the focused `notes`, `folders`, and other commands when available. For other operations, find the operation ID and parameters, then call it:

```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 list comes from the API client bundled with the CLI; older EE servers may not support every operation. Generic writes are not retried automatically. Do not run writes or deletes without the user's authorization.

### Authentication

```bash
Expand All @@ -53,7 +67,7 @@ hackmd-cli notes
hackmd-cli notes --noteId=<id>

# Create note
hackmd-cli notes create --content='# Title' --title='My Note'
hackmd-cli notes create --content='# Title' --title='My Note' --description='Summary'
hackmd-cli notes create --readPermission=owner --writePermission=owner

# Create note inside a folder
Expand All @@ -68,9 +82,12 @@ hackmd-cli notes create -e
# Update note
hackmd-cli notes update --noteId=<id> --content='# New Content'
hackmd-cli notes update --noteId=<id> --title='New title'
hackmd-cli notes update --noteId=<id> --description='New summary'
hackmd-cli notes update --noteId=<id> --clear=description

# Move note into a folder
hackmd-cli notes update --noteId=<id> --parentFolderId=<folder-id>
hackmd-cli notes update --noteId=<id> --root

# Delete note
hackmd-cli notes delete --noteId=<id>
Expand All @@ -82,18 +99,24 @@ hackmd-cli notes delete --noteId=<id>
# List team notes
hackmd-cli team-notes --teamPath=<team-path>

# Get a specific team note
hackmd-cli team-notes --teamPath=<team-path> --noteId=<id>

# Create team note
hackmd-cli team-notes create --teamPath=<team-path> --content='# Team Doc'
hackmd-cli team-notes create --teamPath=<team-path> --content='# Team Doc' --description='Summary'

# Create team note inside a folder
hackmd-cli team-notes create --teamPath=<team-path> --parentFolderId=<folder-id> --content='# Team Doc'

# Update team note
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --content='# Updated'
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --title='New title'
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --description='New summary'
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --clear=description

# Move team note into a folder
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --parentFolderId=<folder-id>
hackmd-cli team-notes update --teamPath=<team-path> --noteId=<id> --root

# Delete team note
hackmd-cli team-notes delete --teamPath=<team-path> --noteId=<id>
Expand All @@ -119,6 +142,8 @@ hackmd-cli folders create --name='Docs' --description='Project docs' --icon=1F60

# Update folder
hackmd-cli folders update --folderId=<id> --name='Updated Docs'
hackmd-cli folders update --folderId=<id> --root
hackmd-cli folders update --folderId=<id> --clear=description --clear=icon --clear=color

# Delete folder
hackmd-cli folders delete --folderId=<id>
Expand Down Expand Up @@ -148,6 +173,8 @@ hackmd-cli team-folders create --teamPath=<team-path> --name='Team Docs' --descr

# Update team folder
hackmd-cli team-folders update --teamPath=<team-path> --folderId=<id> --name='Updated Team Docs'
hackmd-cli team-folders update --teamPath=<team-path> --folderId=<id> --root
hackmd-cli team-folders update --teamPath=<team-path> --folderId=<id> --clear=description

# Delete team folder
hackmd-cli team-folders delete --teamPath=<team-path> --folderId=<id>
Expand All @@ -162,6 +189,7 @@ hackmd-cli team-folders order --teamPath=<team-path> --order='{"root":["folder-i
```bash
hackmd-cli teams # List accessible teams
hackmd-cli history # List browsing history
hackmd-cli history --limit=10 # Limit the number of items
```

### Export
Expand All @@ -180,10 +208,15 @@ Available permission values:
| `--writePermission` | `owner`, `signed_in`, `guest` |
| `--commentPermission` | `disabled`, `forbidden`, `owners`, `signed_in_users`, `everyone` |

## Folder Flags
## Note and Folder Update Flags

Omitted fields stay unchanged. `--description=''` sets an empty string; `--clear=description` removes the description. Do not set and clear the same field in one command.

```bash
--parentFolderId=<folder-id> # Put note/folder inside another folder
--root # Move a note/folder to root (update only)
--clear=description # Clear note/folder description (update only)
--clear=icon --clear=color # Clear folder fields (repeatable, update only)
--icon=1F600 # Emoji unified codepoint string
--color='#4F46E5' # Hex color string
--order='{"root":["id1","id2"]}' # Folder ordering JSON
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
"commands": "./lib/commands",
"bin": "hackmd-cli",
"topicSeparator": " ",
"topics": {
"api": {
"description": "Explore and call HackMD API operations"
}
},
"additionalHelpFlags": [
"-h"
],
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 see available 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 a HackMD API operation'
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))
}
}
}
15 changes: 15 additions & 0 deletions src/commands/api/describe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {Args, Flags} from '@oclif/core'

import {getOperation} from '../../api/operations'
import HackMDCommand from '../../command'

export default class DescribeCommand extends HackMDCommand {
static args = {operationId: Args.string({required: true})}
static description = 'Show details for an API operation'
static flags = {help: Flags.help({char: 'h'})}

async run() {
const {args} = await this.parse(DescribeCommand)
this.log(JSON.stringify({operationId: args.operationId, ...getOperation(args.operationId)}, null, 2))
}
}
17 changes: 17 additions & 0 deletions src/commands/api/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {operationRegistry} from '@hackmd/api/raw'

Check failure on line 1 in src/commands/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 {Flags} from '@oclif/core'

import HackMDCommand from '../../command'

export default class OperationsCommand extends HackMDCommand {
static description = 'List available API operations'
static flags = {help: Flags.help({char: 'h'})}

async run() {
await this.parse(OperationsCommand)
this.log('Available API operations:')
for (const [id, operation] of Object.entries(operationRegistry)) {
this.log(`${id}\t${operation.method} ${operation.path}`)

Check failure on line 14 in src/commands/api/operations.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

'operation' is of type 'unknown'.

Check failure on line 14 in src/commands/api/operations.ts

View workflow job for this annotation

GitHub Actions / Smoke Tests

'operation' is of type 'unknown'.
}
}
}
Loading
Loading