diff --git a/hackmd-cli.skill b/hackmd-cli.skill index be0cb01..e1d62cc 100644 Binary files a/hackmd-cli.skill and b/hackmd-cli.skill differ diff --git a/hackmd-cli/SKILL.md b/hackmd-cli/SKILL.md index d1f8ed4..72f8fff 100644 --- a/hackmd-cli/SKILL.md +++ b/hackmd-cli/SKILL.md @@ -1,11 +1,11 @@ --- name: hackmd-cli -description: HackMD command-line interface for managing notes and team notes. Use this skill when users want to create, read, update, delete, or export HackMD notes via CLI, manage team notes, list teams, view browsing history, or automate HackMD workflows. Triggers on mentions of hackmd-cli, HackMD CLI, or requests to interact with HackMD notes programmatically. +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. --- # HackMD CLI -Command-line tool for managing HackMD notes and team notes via the HackMD API. +Command-line tool for managing HackMD notes, team notes, personal folders, and team folders via the HackMD API. ## Setup @@ -56,6 +56,9 @@ hackmd-cli notes --noteId= hackmd-cli notes create --content='# Title' --title='My Note' hackmd-cli notes create --readPermission=owner --writePermission=owner +# Create note inside a folder +hackmd-cli notes create --parentFolderId= --content='# Title' + # Create from file/stdin cat README.md | hackmd-cli notes create @@ -65,6 +68,9 @@ hackmd-cli notes create -e # Update note hackmd-cli notes update --noteId= --content='# New Content' +# Move note into a folder +hackmd-cli notes update --noteId= --parentFolderId= + # Delete note hackmd-cli notes delete --noteId= ``` @@ -78,13 +84,77 @@ hackmd-cli team-notes --teamPath= # Create team note hackmd-cli team-notes create --teamPath= --content='# Team Doc' +# Create team note inside a folder +hackmd-cli team-notes create --teamPath= --parentFolderId= --content='# Team Doc' + # Update team note hackmd-cli team-notes update --teamPath= --noteId= --content='# Updated' +# Move team note into a folder +hackmd-cli team-notes update --teamPath= --noteId= --parentFolderId= + # Delete team note hackmd-cli team-notes delete --teamPath= --noteId= ``` +### Personal Folders + +```bash +# List all folders +hackmd-cli folders + +# Get specific folder +hackmd-cli folders --folderId= + +# Create folder +hackmd-cli folders create --name='Docs' + +# Create nested folder +hackmd-cli folders create --name='Docs' --parentFolderId= + +# Create folder with metadata +hackmd-cli folders create --name='Docs' --description='Project docs' --icon=1F600 --color='#4F46E5' + +# Update folder +hackmd-cli folders update --folderId= --name='Updated Docs' + +# Delete folder +hackmd-cli folders delete --folderId= + +# Get / update folder order +hackmd-cli folders order +hackmd-cli folders order --order='{"root":["folder-id-1","folder-id-2"]}' +``` + +### Team Folders + +```bash +# List all team folders +hackmd-cli team-folders --teamPath= + +# Get specific team folder +hackmd-cli team-folders --teamPath= --folderId= + +# Create team folder +hackmd-cli team-folders create --teamPath= --name='Team Docs' + +# Create nested team folder +hackmd-cli team-folders create --teamPath= --name='Team Docs' --parentFolderId= + +# Create team folder with metadata +hackmd-cli team-folders create --teamPath= --name='Team Docs' --description='Project docs' --icon=1F600 --color='#4F46E5' + +# Update team folder +hackmd-cli team-folders update --teamPath= --folderId= --name='Updated Team Docs' + +# Delete team folder +hackmd-cli team-folders delete --teamPath= --folderId= + +# Get / update team folder order +hackmd-cli team-folders order --teamPath= +hackmd-cli team-folders order --teamPath= --order='{"root":["folder-id-1","folder-id-2"]}' +``` + ### Teams & History ```bash @@ -102,12 +172,21 @@ hackmd-cli export --noteId= # Export note content to stdout Available permission values: -| Permission Type | Values | -|----------------|--------| -| `--readPermission` | `owner`, `signed_in`, `guest` | -| `--writePermission` | `owner`, `signed_in`, `guest` | +| Permission Type | Values | +| --------------------- | ---------------------------------------------------------------- | +| `--readPermission` | `owner`, `signed_in`, `guest` | +| `--writePermission` | `owner`, `signed_in`, `guest` | | `--commentPermission` | `disabled`, `forbidden`, `owners`, `signed_in_users`, `everyone` | +## Folder Flags + +```bash +--parentFolderId= # Put note/folder inside another folder +--icon=1F600 # Emoji unified codepoint string +--color='#4F46E5' # Hex color string +--order='{"root":["id1","id2"]}' # Folder ordering JSON +``` + ## Output Formats All list commands support: @@ -119,8 +198,10 @@ All list commands support: --no-header # Hide table headers --no-truncate # Don't truncate long values --columns=id,title # Show specific columns +--columns=id,name,color --filter=name=foo # Filter by property --sort=title # Sort by property (prepend '-' for descending) +--sort=name -x, --extended # Show additional columns ``` @@ -136,6 +217,33 @@ cat doc.md | hackmd-cli notes create --title="My Doc" cat doc.md | hackmd-cli notes update --noteId= ``` +### Create a folder and put a note inside it + +```bash +hackmd-cli folders create --name='Docs' +hackmd-cli notes create --parentFolderId= --title='My Note' +``` + +### Create a nested folder + +```bash +hackmd-cli folders create --name='Parent' +hackmd-cli folders create --name='Child' --parentFolderId= +``` + +### Move an existing note into a folder + +```bash +hackmd-cli notes update --noteId= --parentFolderId= +``` + +### Organize team docs in team folders + +```bash +hackmd-cli team-folders create --teamPath= --name='Team Docs' +hackmd-cli team-notes create --teamPath= --parentFolderId= --content='# Team Note' +``` + ### Export note to local file ```bash diff --git a/package.json b/package.json index 7f4451e..06f7d54 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "bugs": "https://github.com/hackmdio/hackmd-cli/issues", "dependencies": { - "@hackmd/api": "^2.5.0", + "@hackmd/api": "2.5.0-beta.20260430192828.99a5117", "@hackmd/oclif-plugin-autocomplete": "^2.1.9-fish", "@oclif/core": "2.8.2", "@oclif/plugin-help": "5.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d5899c..0cde4ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@hackmd/api': - specifier: ^2.5.0 - version: 2.5.0 + specifier: 2.5.0-beta.20260430192828.99a5117 + version: 2.5.0-beta.20260430192828.99a5117 '@hackmd/oclif-plugin-autocomplete': specifier: ^2.1.9-fish version: 2.1.9-fish(@types/node@24.10.8)(typescript@5.9.3) @@ -219,8 +219,8 @@ packages: '@gar/promisify@1.1.3': resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} - '@hackmd/api@2.5.0': - resolution: {integrity: sha512-eG4COWt2odRwiUHgJc3vP73iUS2vuAN1ECpPbKF0kRNJt6YerlsAsf3TNBs/CfVuemsC3g7JeAZpIrT7gmsTRQ==} + '@hackmd/api@2.5.0-beta.20260430192828.99a5117': + resolution: {integrity: sha512-JhAxOqIU6L/Hc6QWw8oq/rYwNzkxKC0J0XBHu+N/zqJezvKSFhdSysCbhKsLuJpBXG5LsVM7ZpnLHuN3f8EOJA==} '@hackmd/oclif-plugin-autocomplete@2.1.9-fish': resolution: {integrity: sha512-wLiROF31bABA+Q9+blweBfythR6HuUANsdYql+MJNb+R1Z6/JJvyHvtAfA6u+mcT4quLJeU5xZ0timWgfDkhlw==} @@ -3979,7 +3979,7 @@ snapshots: '@gar/promisify@1.1.3': {} - '@hackmd/api@2.5.0': + '@hackmd/api@2.5.0-beta.20260430192828.99a5117': dependencies: axios: 1.13.2 tslib: 1.14.1 @@ -5594,7 +5594,7 @@ snapshots: eslint-config-xo: 0.49.0(eslint@9.39.2) eslint-config-xo-space: 0.35.0(eslint@9.39.2) eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2) eslint-plugin-mocha: 10.5.0(eslint@9.39.2) eslint-plugin-n: 17.23.2(eslint@9.39.2)(typescript@5.9.3) @@ -5646,7 +5646,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2) transitivePeerDependencies: - supports-color @@ -5674,7 +5674,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.53.0(eslint@9.39.2)(typescript@5.9.3))(eslint@9.39.2))(eslint@9.39.2))(eslint@9.39.2): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 diff --git a/src/commands/folders/create.ts b/src/commands/folders/create.ts new file mode 100644 index 0000000..2968648 --- /dev/null +++ b/src/commands/folders/create.ts @@ -0,0 +1,83 @@ + +import type {CreateUserFolderBody} from '@hackmd/api' + +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import { + folderColor, + folderDescription, + folderIcon, + folderName, + parentFolderId, +} from '../../flags' + +export default class Create extends HackMDCommand { + static description = 'Create a folder' + static examples = [ + `$ hackmd-cli folders create --name='docs' --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --description='Docs' --icon=1F600 --color=#4F46E5 +ID Name Parent Folder ID Color Description Icon +──────────────────────────────────── ──── ──────────────────────────────────── ─────── ─────────── ───── +a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d docs fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c #4F46E5 Docs 1F600`, + ] + static flags = { + color: folderColor, + description: folderDescription, + help: Flags.help({char: 'h'}), + icon: folderIcon, + name: folderName, + parentFolderId, + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(Create) + const {color, description, icon, name, parentFolderId} = flags + + if (!name) { + this.error('Flag name could not be empty') + } + + const payload: CreateUserFolderBody = { + color, + description, + icon, + name, + parentFolderId, + } + + try { + const APIClient = await this.getAPIClient() + const folder = await APIClient.createFolder(payload) + + ux.table( + [folder], + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Create folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/folders/delete.ts b/src/commands/folders/delete.ts new file mode 100644 index 0000000..9fac55a --- /dev/null +++ b/src/commands/folders/delete.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderId} from '../../flags' + +export default class Delete extends HackMDCommand { + static description = 'Delete a folder' + static examples = [ + '$ hackmd-cli folders delete --folderId=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + ] + static flags = { + folderId, + help: Flags.help({char: 'h'}), + } + + async run() { + const {flags} = await this.parse(Delete) + const {folderId} = flags + + if (!folderId) { + this.error('Flag folderId could not be empty') + } + + try { + const APIClient = await this.getAPIClient() + await APIClient.deleteFolder(folderId) + } catch (error) { + this.log('Delete folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/folders/index.ts b/src/commands/folders/index.ts new file mode 100644 index 0000000..870ebe1 --- /dev/null +++ b/src/commands/folders/index.ts @@ -0,0 +1,57 @@ +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderId} from '../../flags' + +export default class IndexCommand extends HackMDCommand { + static description = 'HackMD folders commands' + static examples = [ + `$ hackmd-cli folders +ID Color Description Icon Name Parent Folder ID +──────────────────────────────────── ─────── ───────────────────── ───── ─────────── ──────────────────────────────────── +91722050-bf47-4334-9e5d-87125a724c29 #4F46E5 Project documentation 1F600 engineering fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c`, + ] + static flags = { + folderId, + help: Flags.help({char: 'h'}), + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(IndexCommand) + + try { + const APIClient = await this.getAPIClient() + const folders = flags.folderId ? [await APIClient.getFolder(flags.folderId)] : await APIClient.getFolderList() + + ux.table( + folders, + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Fetch folders failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/folders/order.ts b/src/commands/folders/order.ts new file mode 100644 index 0000000..63f1497 --- /dev/null +++ b/src/commands/folders/order.ts @@ -0,0 +1,39 @@ +import {Flags} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderOrder} from '../../flags' +import {parseFolderOrder} from '../../utils' + +export default class Order extends HackMDCommand { + static description = 'Get or update folder order' + static examples = [ + '$ hackmd-cli folders order', + '$ hackmd-cli folders order --order=\'{"root":["91722050-bf47-4334-9e5d-87125a724c29","fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c"]}\'', + ] + static flags = { + help: Flags.help({char: 'h'}), + order: folderOrder, + } + + async run() { + const {flags} = await this.parse(Order) + + try { + const APIClient = await this.getAPIClient() + + if (flags.order) { + await APIClient.updateFolderOrder({ + order: parseFolderOrder(flags.order), + }) + this.log('Folder order updated') + return + } + + const order = await APIClient.getFolderOrder() + this.log(JSON.stringify(order, null, 2)) + } catch (error) { + this.log('Update folder order failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/folders/update.ts b/src/commands/folders/update.ts new file mode 100644 index 0000000..c9e1fe6 --- /dev/null +++ b/src/commands/folders/update.ts @@ -0,0 +1,84 @@ +import type {UpdateUserFolderBody} from '@hackmd/api' + +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import { + folderColor, + folderDescription, + folderIcon, + folderId, + folderName, + parentFolderId, +} from '../../flags' + +export default class Update extends HackMDCommand { + static description = 'Update folder' + static examples = [ + `$ hackmd-cli folders update --folderId=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d --name='docs' --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --description='Docs' --icon=1F600 --color=#4F46E5 +ID Name Parent Folder ID Color Description Icon +──────────────────────────────────── ──── ──────────────────────────────────── ─────── ─────────── ───── +a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d docs fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c #4F46E5 Docs 1F600`, + ] + static flags = { + color: folderColor, + description: folderDescription, + folderId, + help: Flags.help({char: 'h'}), + icon: folderIcon, + name: folderName, + parentFolderId, + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(Update) + const {color, description, folderId, icon, name, parentFolderId} = flags + + if (!folderId) { + this.error('Flag folderId could not be empty') + } + + const payload: UpdateUserFolderBody = { + color, + description, + icon, + name, + parentFolderId, + } + + try { + const APIClient = await this.getAPIClient() + const folder = await APIClient.updateFolder(folderId, payload) + + ux.table( + [folder], + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Update folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/notes/create.ts b/src/commands/notes/create.ts index f8f3ac9..d0e9e91 100644 --- a/src/commands/notes/create.ts +++ b/src/commands/notes/create.ts @@ -1,8 +1,9 @@ -import { +import type { CommentPermissionType, CreateNoteOptions, NotePermissionRole, -} from '@hackmd/api/dist/type' +} from '@hackmd/api' + import {Flags, ux} from '@oclif/core' import * as fs from 'node:fs' @@ -13,6 +14,7 @@ import { noteContent, notePermission, noteTitle, + parentFolderId, } from '../../flags' import {openEditor} from '../../open-editor' import {safeStdinRead, temporaryMD} from '../../utils' @@ -20,11 +22,20 @@ import {safeStdinRead, temporaryMD} from '../../utils' export default class CreateCommand extends HackMDCommand { static description = 'Create a note' static examples = [ - "notes create --content='# A new note' --readPermission=owner --writePermission=owner --commentPermission=disabled", + `$ hackmd-cli notes create --content='# A new note' --readPermission=owner --writePermission=owner --commentPermission=disabled +ID Title User Path Team Path +────────────────────── ──────────────────────────────── ────────────────────── ──────── +raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q null `, - `ID Title User Path Team Path -────────────────────── ──────────────────────────────── ────────────────────── ──────── -raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q null`, + [ + '$ hackmd-cli notes create ', + '--parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c ', + "--content='# A new note' --readPermission=owner ", + '--writePermission=owner --commentPermission=disabled\n', + 'ID Title User Path Team Path\n', + '────────────────────── ──────────────────────────────── ────────────────────── ────────\n', + 'raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q null ', + ].join(''), 'Or you can pipe content via Unix pipeline:', 'cat README.md | hackmd-cli notes create', @@ -34,6 +45,7 @@ raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q content: noteContent, editor, help: Flags.help({char: 'h'}), + parentFolderId, readPermission: notePermission, title: noteTitle, writePermission: notePermission, @@ -47,6 +59,7 @@ raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q const options: CreateNoteOptions = { commentPermission: flags.commentPermission as CommentPermissionType, content: pipeString || flags.content, + parentFolderId: flags.parentFolderId, readPermission: flags.readPermission as NotePermissionRole, title: flags.title, writePermission: flags.writePermission as NotePermissionRole, diff --git a/src/commands/notes/update.ts b/src/commands/notes/update.ts index 5339507..17a30aa 100644 --- a/src/commands/notes/update.ts +++ b/src/commands/notes/update.ts @@ -1,30 +1,39 @@ +import type {UpdateNoteOptions} from '@hackmd/api' + import {Flags} from '@oclif/core' import HackMDCommand from '../../command' -import {noteContent, noteId} from '../../flags' +import {noteContent, noteId, parentFolderId} from '../../flags' export default class Update extends HackMDCommand { static description = 'Update note content' static examples = [ "$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --content='# A new title'", + "$ hackmd-cli notes update --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --content='# A new title'", ] static flags = { content: noteContent, help: Flags.help({char: 'h'}), noteId, + parentFolderId, } async run() { const {flags} = await this.parse(Update) - const {content, noteId} = flags + const {content, noteId, parentFolderId} = flags if (!noteId) { this.error('Flag noteId could not be empty') } + const payload: UpdateNoteOptions = { + content, + parentFolderId, + } + try { const APIClient = await this.getAPIClient() - await APIClient.updateNoteContent(noteId, content) + await APIClient.updateNote(noteId, payload) } catch (error) { this.log('Update note content failed') this.error(error as Error) diff --git a/src/commands/team-folders/create.ts b/src/commands/team-folders/create.ts new file mode 100644 index 0000000..bf7cb24 --- /dev/null +++ b/src/commands/team-folders/create.ts @@ -0,0 +1,88 @@ +import type {CreateTeamFolderBody} from '@hackmd/api' + +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import { + folderColor, + folderDescription, + folderIcon, + folderName, + parentFolderId, + teamPath, +} from '../../flags' + +export default class Create extends HackMDCommand { + static description = 'Create a team folder' + static examples = [ + `$ hackmd-cli team-folders create --teamPath=CLI-test --name='team-docs' --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --description='Docs' --icon=1F600 --color=#4F46E5 +ID Name Parent Folder ID Color Description Icon +──────────────────────────────────── ───────── ──────────────────────────────────── ─────── ─────────── ───── +a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d team-docs fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c #4F46E5 Docs 1F600`, + ] + static flags = { + color: folderColor, + description: folderDescription, + help: Flags.help({char: 'h'}), + icon: folderIcon, + name: folderName, + parentFolderId, + teamPath, + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(Create) + const {color, description, icon, name, parentFolderId, teamPath} = flags + + if (!teamPath) { + this.error('Flag teamPath could not be empty') + } + + if (!name) { + this.error('Flag name could not be empty') + } + + const payload: CreateTeamFolderBody = { + color, + description, + icon, + name, + parentFolderId, + } + + try { + const APIClient = await this.getAPIClient() + const folder = await APIClient.createTeamFolder(teamPath, payload) + + ux.table( + [folder], + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Create team folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/team-folders/delete.ts b/src/commands/team-folders/delete.ts new file mode 100644 index 0000000..8026de7 --- /dev/null +++ b/src/commands/team-folders/delete.ts @@ -0,0 +1,37 @@ +import {Flags} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderId, teamPath} from '../../flags' + +export default class Delete extends HackMDCommand { + static description = 'Delete a team folder' + static examples = [ + '$ hackmd-cli team-folders delete --teamPath=CLI-test --folderId=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d', + ] + static flags = { + folderId, + help: Flags.help({char: 'h'}), + teamPath, + } + + async run() { + const {flags} = await this.parse(Delete) + const {folderId, teamPath} = flags + + if (!teamPath) { + this.error('Flag teamPath could not be empty') + } + + if (!folderId) { + this.error('Flag folderId could not be empty') + } + + try { + const APIClient = await this.getAPIClient() + await APIClient.deleteTeamFolder(teamPath, folderId) + } catch (error) { + this.log('Delete team folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/team-folders/index.ts b/src/commands/team-folders/index.ts new file mode 100644 index 0000000..10f225c --- /dev/null +++ b/src/commands/team-folders/index.ts @@ -0,0 +1,62 @@ +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderId, teamPath} from '../../flags' + +export default class IndexCommand extends HackMDCommand { + static description = 'HackMD team folders commands' + static examples = [ + `$ hackmd-cli team-folders --teamPath engineering +ID Color Description Icon Name Parent Folder ID +──────────────────────────────────── ─────── ─────────────── ───── ────────── ──────────────────────────────────── +91722050-bf47-4334-9e5d-87125a724c29 #4F46E5 Team handbook 1F600 team-docs fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c`, + ] + static flags = { + folderId, + help: Flags.help({char: 'h'}), + teamPath, + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(IndexCommand) + + if (!flags.teamPath) { + this.error('Flag teamPath could not be empty') + } + + try { + const APIClient = await this.getAPIClient() + const folders = flags.folderId ? [await APIClient.getTeamFolder(flags.teamPath, flags.folderId)] : await APIClient.getTeamFolderList(flags.teamPath) + + ux.table( + folders, + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Fetch team folders failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/team-folders/order.ts b/src/commands/team-folders/order.ts new file mode 100644 index 0000000..1ba0472 --- /dev/null +++ b/src/commands/team-folders/order.ts @@ -0,0 +1,44 @@ +import {Flags} from '@oclif/core' + +import HackMDCommand from '../../command' +import {folderOrder, teamPath} from '../../flags' +import {parseFolderOrder} from '../../utils' + +export default class Order extends HackMDCommand { + static description = 'Get or update team folder order' + static examples = [ + '$ hackmd-cli team-folders order --teamPath=CLI-test', + '$ hackmd-cli team-folders order --teamPath=CLI-test --order=\'{"root":["91722050-bf47-4334-9e5d-87125a724c29","fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c"]}\'', + ] + static flags = { + help: Flags.help({char: 'h'}), + order: folderOrder, + teamPath, + } + + async run() { + const {flags} = await this.parse(Order) + + if (!flags.teamPath) { + this.error('Flag teamPath could not be empty') + } + + try { + const APIClient = await this.getAPIClient() + + if (flags.order) { + await APIClient.updateTeamFolderOrder(flags.teamPath, { + order: parseFolderOrder(flags.order), + }) + this.log('Team folder order updated') + return + } + + const order = await APIClient.getTeamFolderOrder(flags.teamPath) + this.log(JSON.stringify(order, null, 2)) + } catch (error) { + this.log('Update team folder order failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/team-folders/update.ts b/src/commands/team-folders/update.ts new file mode 100644 index 0000000..c66b8a2 --- /dev/null +++ b/src/commands/team-folders/update.ts @@ -0,0 +1,96 @@ +import type {UpdateTeamFolderBody} from '@hackmd/api' + +import {Flags, ux} from '@oclif/core' + +import HackMDCommand from '../../command' +import { + folderColor, + folderDescription, + folderIcon, + folderId, + folderName, + parentFolderId, + teamPath, +} from '../../flags' + +export default class Update extends HackMDCommand { + static description = 'Update team folder' + static examples = [ + [ + '$ hackmd-cli team-folders update --teamPath=CLI-test ', + '--folderId=a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d ', + "--name='team-docs' ", + '--parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c ', + "--description='Docs' --icon=1F600 --color=#4F46E5\n", + 'ID Name Parent Folder ID Color Description Icon\n', + '──────────────────────────────────── ───────── ──────────────────────────────────── ─────── ─────────── ─────\n', + 'a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d team-docs fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c #4F46E5 Docs 1F600', + ].join(''), + ] + static flags = { + color: folderColor, + description: folderDescription, + folderId, + help: Flags.help({char: 'h'}), + icon: folderIcon, + name: folderName, + parentFolderId, + teamPath, + ...ux.table.flags(), + } + + async run() { + const {flags} = await this.parse(Update) + const {color, description, folderId, icon, name, parentFolderId, teamPath} = flags + + if (!teamPath) { + this.error('Flag teamPath could not be empty') + } + + if (!folderId) { + this.error('Flag folderId could not be empty') + } + + const payload: UpdateTeamFolderBody = { + color, + description, + icon, + name, + parentFolderId, + } + + try { + const APIClient = await this.getAPIClient() + const folder = await APIClient.updateTeamFolder(teamPath, folderId, payload) + + ux.table( + [folder], + Object.fromEntries([ + [ + 'id', + { + header: 'ID', + }, + ], + ['name', {}], + [ + 'parentFolderId', + { + header: 'Parent Folder ID', + }, + ], + ['color', {}], + ['description', {}], + ['icon', {}], + ]), + { + printLine: this.log.bind(this), + ...flags, + }, + ) + } catch (error) { + this.log('Update team folder failed') + this.error(error as Error) + } + } +} diff --git a/src/commands/team-notes/create.ts b/src/commands/team-notes/create.ts index 137ac4b..8178927 100644 --- a/src/commands/team-notes/create.ts +++ b/src/commands/team-notes/create.ts @@ -1,10 +1,11 @@ -import {CommentPermissionType, CreateNoteOptions, NotePermissionRole} from '@hackmd/api/dist/type' +import type {CommentPermissionType, CreateNoteOptions, NotePermissionRole} from '@hackmd/api' + import {Flags, ux} from '@oclif/core' import fs from 'node:fs' import HackMDCommand from '../../command' import { - commentPermission, editor, noteContent, notePermission, noteTitle, teamPath, + commentPermission, editor, noteContent, notePermission, noteTitle, parentFolderId, teamPath, } from '../../flags' import {openEditor} from '../../open-editor' import {safeStdinRead, temporaryMD} from '../../utils' @@ -12,19 +13,30 @@ import {safeStdinRead, temporaryMD} from '../../utils' export default class Create extends HackMDCommand { static description = 'Create a team note' static examples = [ - `team-notes:create --teamPath=CLI-test --content='# A new note' --readPermission=owner --writePermission=owner --commentPermission=disabled + `$ hackmd-cli team-notes create --teamPath=CLI-test --content='# A new note' --readPermission=owner --writePermission=owner --commentPermission=disabled ID Title User Path Team Path ────────────────────── ──────────────────────────────── ────────────────────── ──────── raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q null `, + [ + '$ hackmd-cli team-notes create --teamPath=CLI-test ', + '--parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c ', + "--content='# A new note' --readPermission=owner ", + '--writePermission=owner --commentPermission=disabled\n', + 'ID Title User Path Team Path\n', + '────────────────────── ──────────────────────────────── ────────────────────── ────────\n', + 'raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q null ', + ].join(''), + 'Or you can pipe content via Unix pipeline:', - 'cat README.md | hackmd-cli notes create --teamPath=CLI-test', + 'cat README.md | hackmd-cli team-notes create --teamPath=CLI-test', ] static flags = { commentPermission, content: noteContent, editor, help: Flags.help({char: 'h'}), + parentFolderId, readPermission: notePermission, teamPath, title: noteTitle, @@ -36,10 +48,11 @@ raUuSTetT5uQbqQfLnz9lA A new note gvfz2UB5THiKABQJQnLs6Q n const {flags} = await this.parse(Create) const pipeString = safeStdinRead() - const {commentPermission, content, readPermission, teamPath, title, writePermission} = flags + const {commentPermission, content, parentFolderId, readPermission, teamPath, title, writePermission} = flags const options: CreateNoteOptions = { commentPermission: commentPermission as CommentPermissionType, content: pipeString || content, + parentFolderId, readPermission: readPermission as NotePermissionRole, title, writePermission: writePermission as NotePermissionRole, diff --git a/src/commands/team-notes/update.ts b/src/commands/team-notes/update.ts index 983bd78..9683772 100644 --- a/src/commands/team-notes/update.ts +++ b/src/commands/team-notes/update.ts @@ -1,23 +1,29 @@ +import type {UpdateNoteOptions} from '@hackmd/api' + import {Flags} from '@oclif/core' import HackMDCommand from '../../command' -import {noteContent, noteId, teamPath} from '../../flags' +import { + noteContent, noteId, parentFolderId, teamPath, +} from '../../flags' export default class Update extends HackMDCommand { static description = 'Update team note content' static examples = [ "$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --content='# A new title'", + "$ hackmd-cli team-notes update --teamPath=CLI-test --noteId=WNkLM6gkS0Cg2cQ8rv7bYA --parentFolderId=fc7a3d48-4a07-4cbf-bf4f-e65dd896e01c --content='# A new title'", ] static flags = { content: noteContent, help: Flags.help({char: 'h'}), noteId, + parentFolderId, teamPath, } async run() { const {flags} = await this.parse(Update) - const {content, noteId, teamPath} = flags + const {content, noteId, parentFolderId, teamPath} = flags if (!teamPath) { this.error('Flag teamPath could not be empty') @@ -27,9 +33,14 @@ export default class Update extends HackMDCommand { this.error('Flag noteId could not be empty') } + const payload: UpdateNoteOptions = { + content, + parentFolderId, + } + try { const APIClient = await this.getAPIClient() - await APIClient.updateTeamNoteContent(teamPath, noteId, content) + await APIClient.updateTeamNote(teamPath, noteId, payload) } catch (error) { this.log('Update team note content failed') this.error(error as Error) diff --git a/src/flags.ts b/src/flags.ts index b9a71ae..6915942 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -3,6 +3,10 @@ export const noteId = Flags.string({ description: 'HackMD note id', }) +export const folderId = Flags.string({ + description: 'HackMD folder id', +}) + export const teamPath = Flags.string({ description: 'HackMD team path', }) @@ -15,6 +19,30 @@ export const noteTitle = Flags.string({ description: 'new note title', }) +export const folderName = Flags.string({ + description: 'folder name', +}) + +export const folderDescription = Flags.string({ + description: 'folder description', +}) + +export const folderIcon = Flags.string({ + description: 'folder icon', +}) + +export const folderColor = Flags.string({ + description: 'folder color', +}) + +export const parentFolderId = Flags.string({ + description: 'parent folder id', +}) + +export const folderOrder = Flags.string({ + description: 'folder order JSON, e.g. {"root":["folder-id"]}', +}) + export const notePermission = Flags.string({ description: 'set note permission: owner, signed_in, guest', }) diff --git a/src/utils.ts b/src/utils.ts index eed6e0f..4dfcf5a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import type {ApiFolderOrder} from '@hackmd/api' + import fs from 'fs-extra' import {homedir, tmpdir} from 'node:os' import path from 'node:path' @@ -44,3 +46,19 @@ export function temporaryMD() { return filePath } + +export function parseFolderOrder(order: string): ApiFolderOrder { + const parsed = JSON.parse(order) + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Folder order must be a JSON object') + } + + for (const [key, value] of Object.entries(parsed)) { + if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) { + throw new Error(`Folder order entry "${key}" must be an array of folder ids`) + } + } + + return parsed as ApiFolderOrder +} diff --git a/test/smoke/cli.test.ts b/test/smoke/cli.test.ts index 84a296f..e18ccf5 100644 --- a/test/smoke/cli.test.ts +++ b/test/smoke/cli.test.ts @@ -206,6 +206,38 @@ describe('Smoke Tests: Built CLI Binary', () => { expect(result.stdout.toLowerCase()).to.include('logout') } }) + + it('should recognize folders command', async () => { + const result = await runCLI(['folders', '--help']) + expect(result.stdout || result.stderr).to.exist + if (result.code === 0) { + expect(result.stdout.toLowerCase()).to.include('folders') + } + }) + + it('should recognize folders create command', async () => { + const result = await runCLI(['folders', 'create', '--help']) + expect(result.stdout || result.stderr).to.exist + if (result.code === 0) { + expect(result.stdout.toLowerCase()).to.include('folder') + } + }) + + it('should recognize team-folders command', async () => { + const result = await runCLI(['team-folders', '--help']) + expect(result.stdout || result.stderr).to.exist + if (result.code === 0) { + expect(result.stdout.toLowerCase()).to.include('team') + } + }) + + it('should recognize team-folders create command', async () => { + const result = await runCLI(['team-folders', 'create', '--help']) + expect(result.stdout || result.stderr).to.exist + if (result.code === 0) { + expect(result.stdout.toLowerCase()).to.include('folder') + } + }) }) describe('Error handling', () => {