Skip to main content

hackmd/cli/
mod.rs

1//! Clap-based CLI surface mirroring `@hackmd/hackmd-cli`.
2//!
3//! Module entry-point: [`Cli::parse`] (via clap) yields a [`Cli`] value
4//! that [`dispatch`] consumes to drive command handlers under
5//! [`commands`].
6
7pub mod commands;
8pub mod config;
9pub mod editor;
10pub mod output;
11
12use std::path::PathBuf;
13
14use clap::{Args, Parser, Subcommand};
15
16use crate::error::Result;
17use crate::types::{CommentPermissionType, NotePermissionRole};
18
19use self::output::OutputOpts;
20
21/// Top-level CLI. Mirrors the `hackmd-cli` upstream surface.
22#[derive(Debug, Parser)]
23#[command(
24    name = "hackmd",
25    version,
26    about = "HackMD CLI — manage notes, teams, and folders from the terminal",
27    long_about = None
28)]
29pub struct Cli {
30    /// Override the config directory (defaults to `~/.hackmd`).
31    #[arg(long = "config-dir", env = config::ENV_CONFIG_DIR, global = true)]
32    pub config_dir: Option<PathBuf>,
33
34    /// Override the API endpoint URL.
35    #[arg(long = "endpoint", env = config::ENV_ENDPOINT_URL, global = true)]
36    pub endpoint: Option<String>,
37
38    /// Override the access token.
39    #[arg(long = "token", env = config::ENV_ACCESS_TOKEN, global = true, hide_env_values = true)]
40    pub token: Option<String>,
41
42    #[command(subcommand)]
43    pub command: Command,
44}
45
46/// Top-level subcommands.
47#[derive(Debug, Subcommand)]
48pub enum Command {
49    /// Login to HackMD (prompts for an access token).
50    Login,
51    /// Clear the stored access token.
52    Logout,
53    /// Print the authenticated user.
54    Whoami(WhoamiArgs),
55    /// List the user's browse history.
56    History(HistoryArgs),
57    /// Export a note's raw markdown content to stdout.
58    Export(ExportArgs),
59    /// List teams.
60    Teams(TeamsArgs),
61    /// Manage notes (list, get, create, update, delete).
62    Notes(NotesArgs),
63    /// Manage team notes (list, create, update, delete).
64    #[command(name = "team-notes")]
65    TeamNotes(TeamNotesArgs),
66    /// Launch the TUI (requires the `tui` feature at build time).
67    Tui,
68}
69
70// ─── Top-level command argument structs ────────────────────────────────────
71
72#[derive(Debug, Args)]
73pub struct WhoamiArgs {
74    #[command(flatten)]
75    pub output: OutputOpts,
76}
77
78#[derive(Debug, Args)]
79pub struct HistoryArgs {
80    #[command(flatten)]
81    pub output: OutputOpts,
82}
83
84#[derive(Debug, Args)]
85pub struct ExportArgs {
86    /// HackMD note id.
87    #[arg(long = "note-id")]
88    pub note_id: String,
89}
90
91#[derive(Debug, Args)]
92pub struct TeamsArgs {
93    #[command(flatten)]
94    pub output: OutputOpts,
95}
96
97// ─── `notes` ────────────────────────────────────────────────────────────────
98
99#[derive(Debug, Args)]
100pub struct NotesArgs {
101    #[command(subcommand)]
102    pub action: NotesCmd,
103}
104
105#[derive(Debug, Subcommand)]
106pub enum NotesCmd {
107    /// List the user's notes.
108    List(NotesListArgs),
109    /// Fetch a single note by id.
110    Get(NotesGetArgs),
111    /// Create a new note.
112    Create(NotesCreateArgs),
113    /// Update an existing note's content.
114    Update(NotesUpdateArgs),
115    /// Delete a note.
116    Delete(NotesDeleteArgs),
117}
118
119#[derive(Debug, Args)]
120pub struct NotesListArgs {
121    #[command(flatten)]
122    pub output: OutputOpts,
123}
124
125#[derive(Debug, Args)]
126pub struct NotesGetArgs {
127    #[arg(long = "note-id")]
128    pub note_id: String,
129    #[command(flatten)]
130    pub output: OutputOpts,
131}
132
133#[derive(Debug, Args)]
134pub struct NotesCreateArgs {
135    /// Note title.
136    #[arg(long = "title")]
137    pub title: Option<String>,
138    /// Note content (raw markdown).
139    #[arg(long = "content")]
140    pub content: Option<String>,
141    /// `owner`, `signed_in`, or `guest`.
142    #[arg(long = "read-permission", value_enum)]
143    pub read_permission: Option<PermArg>,
144    /// `owner`, `signed_in`, or `guest`.
145    #[arg(long = "write-permission", value_enum)]
146    pub write_permission: Option<PermArg>,
147    /// `disabled`, `forbidden`, `owners`, `signed_in_users`, or `everyone`.
148    #[arg(long = "comment-permission", value_enum)]
149    pub comment_permission: Option<CommentArg>,
150    /// Open `$EDITOR` to author the content interactively.
151    #[arg(short = 'e', long = "editor")]
152    pub editor: bool,
153    #[command(flatten)]
154    pub output: OutputOpts,
155}
156
157#[derive(Debug, Args)]
158pub struct NotesUpdateArgs {
159    #[arg(long = "note-id")]
160    pub note_id: String,
161    /// Replacement markdown content.
162    #[arg(long = "content")]
163    pub content: Option<String>,
164}
165
166#[derive(Debug, Args)]
167pub struct NotesDeleteArgs {
168    #[arg(long = "note-id")]
169    pub note_id: String,
170}
171
172// ─── `team-notes` ───────────────────────────────────────────────────────────
173
174#[derive(Debug, Args)]
175pub struct TeamNotesArgs {
176    /// HackMD team path. Required for every team-notes subcommand.
177    #[arg(long = "team-path", global = true)]
178    pub team_path: Option<String>,
179
180    #[command(subcommand)]
181    pub action: TeamNotesCmd,
182}
183
184#[derive(Debug, Subcommand)]
185pub enum TeamNotesCmd {
186    /// List notes belonging to the given team.
187    List(TeamNotesListArgs),
188    /// Create a note in the given team.
189    Create(TeamNotesCreateArgs),
190    /// Update a team note's content.
191    Update(TeamNotesUpdateArgs),
192    /// Delete a team note.
193    Delete(TeamNotesDeleteArgs),
194}
195
196#[derive(Debug, Args)]
197pub struct TeamNotesListArgs {
198    #[command(flatten)]
199    pub output: OutputOpts,
200}
201
202#[derive(Debug, Args)]
203pub struct TeamNotesCreateArgs {
204    #[arg(long = "title")]
205    pub title: Option<String>,
206    #[arg(long = "content")]
207    pub content: Option<String>,
208    #[arg(long = "read-permission", value_enum)]
209    pub read_permission: Option<PermArg>,
210    #[arg(long = "write-permission", value_enum)]
211    pub write_permission: Option<PermArg>,
212    #[arg(long = "comment-permission", value_enum)]
213    pub comment_permission: Option<CommentArg>,
214    #[arg(short = 'e', long = "editor")]
215    pub editor: bool,
216    #[command(flatten)]
217    pub output: OutputOpts,
218}
219
220#[derive(Debug, Args)]
221pub struct TeamNotesUpdateArgs {
222    #[arg(long = "note-id")]
223    pub note_id: String,
224    #[arg(long = "content")]
225    pub content: Option<String>,
226}
227
228#[derive(Debug, Args)]
229pub struct TeamNotesDeleteArgs {
230    #[arg(long = "note-id")]
231    pub note_id: String,
232}
233
234// ─── Enum adapters for clap ────────────────────────────────────────────────
235
236/// CLI-friendly mirror of [`NotePermissionRole`]. Kept separate from the
237/// SDK enum so we can use `clap::ValueEnum` without polluting the public
238/// SDK surface.
239#[derive(Debug, Clone, Copy, clap::ValueEnum)]
240#[clap(rename_all = "snake_case")]
241pub enum PermArg {
242    Owner,
243    SignedIn,
244    Guest,
245}
246
247impl From<PermArg> for NotePermissionRole {
248    fn from(v: PermArg) -> Self {
249        match v {
250            PermArg::Owner => NotePermissionRole::Owner,
251            PermArg::SignedIn => NotePermissionRole::SignedIn,
252            PermArg::Guest => NotePermissionRole::Guest,
253        }
254    }
255}
256
257/// CLI mirror of [`CommentPermissionType`].
258#[derive(Debug, Clone, Copy, clap::ValueEnum)]
259#[clap(rename_all = "snake_case")]
260pub enum CommentArg {
261    Disabled,
262    Forbidden,
263    Owners,
264    SignedInUsers,
265    Everyone,
266}
267
268impl From<CommentArg> for CommentPermissionType {
269    fn from(v: CommentArg) -> Self {
270        match v {
271            CommentArg::Disabled => CommentPermissionType::Disabled,
272            CommentArg::Forbidden => CommentPermissionType::Forbidden,
273            CommentArg::Owners => CommentPermissionType::Owners,
274            CommentArg::SignedInUsers => CommentPermissionType::SignedInUsers,
275            CommentArg::Everyone => CommentPermissionType::Everyone,
276        }
277    }
278}
279
280// ─── Dispatch ──────────────────────────────────────────────────────────────
281
282/// Run the parsed CLI value.
283pub async fn dispatch(cli: Cli) -> Result<()> {
284    let config_dir = cli.config_dir.as_deref();
285    let endpoint = cli.endpoint.as_deref();
286    let token = cli.token.as_deref();
287
288    match cli.command {
289        Command::Login => commands::auth::login(config_dir, endpoint, token).await,
290        Command::Logout => commands::auth::logout(config_dir),
291        Command::Whoami(args) => {
292            commands::auth::whoami(config_dir, endpoint, token, &args.output).await
293        }
294        Command::History(args) => {
295            commands::history::run(config_dir, endpoint, token, &args.output).await
296        }
297        Command::Export(args) => {
298            commands::export::run(config_dir, endpoint, token, &args.note_id).await
299        }
300        Command::Teams(args) => {
301            commands::teams::run(config_dir, endpoint, token, &args.output).await
302        }
303        Command::Notes(n) => match n.action {
304            NotesCmd::List(a) => {
305                commands::notes::list(config_dir, endpoint, token, &a.output).await
306            }
307            NotesCmd::Get(a) => {
308                commands::notes::get(config_dir, endpoint, token, &a.note_id, &a.output).await
309            }
310            NotesCmd::Create(a) => {
311                commands::notes::create(
312                    config_dir,
313                    endpoint,
314                    token,
315                    a.title,
316                    a.content,
317                    a.read_permission.map(Into::into),
318                    a.write_permission.map(Into::into),
319                    a.comment_permission.map(Into::into),
320                    a.editor,
321                    &a.output,
322                )
323                .await
324            }
325            NotesCmd::Update(a) => {
326                commands::notes::update(config_dir, endpoint, token, &a.note_id, a.content).await
327            }
328            NotesCmd::Delete(a) => {
329                commands::notes::delete(config_dir, endpoint, token, &a.note_id).await
330            }
331        },
332        Command::TeamNotes(t) => {
333            let team_path = t.team_path.clone().ok_or_else(|| {
334                crate::error::Error::Config(
335                    "--team-path is required for `team-notes` subcommands".into(),
336                )
337            })?;
338            match t.action {
339                TeamNotesCmd::List(a) => {
340                    commands::team_notes::list(config_dir, endpoint, token, &team_path, &a.output)
341                        .await
342                }
343                TeamNotesCmd::Create(a) => {
344                    commands::team_notes::create(
345                        config_dir,
346                        endpoint,
347                        token,
348                        &team_path,
349                        a.title,
350                        a.content,
351                        a.read_permission.map(Into::into),
352                        a.write_permission.map(Into::into),
353                        a.comment_permission.map(Into::into),
354                        a.editor,
355                        &a.output,
356                    )
357                    .await
358                }
359                TeamNotesCmd::Update(a) => {
360                    commands::team_notes::update(
361                        config_dir, endpoint, token, &team_path, &a.note_id, a.content,
362                    )
363                    .await
364                }
365                TeamNotesCmd::Delete(a) => {
366                    commands::team_notes::delete(
367                        config_dir, endpoint, token, &team_path, &a.note_id,
368                    )
369                    .await
370                }
371            }
372        }
373        Command::Tui => commands::tui::run(config_dir, endpoint, token).await,
374    }
375}