semora-cli 0.8.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Command-line arguments.

use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

#[derive(Debug, Parser)]
#[command(
    name = "semora",
    version,
    about = "Publish Markdown and HTML to Semora and hear back from the page",
    after_help = "Configuration: SEMORA_URL, SEMORA_API_KEY, SEMORA_DEFAULT_TAGS, SEMORA_WAIT_TIMEOUT \
                  (environment or a .env file in this folder or a parent), or `semora login`. \
                  A `.semora` file marks a project folder (see `semora project init`).\n\
                  Exit codes: 0 ok, 1 error, 2 timeout, 3 auth error, 4 not found, \
                  5 newer on the server (push)."
)]
pub struct Cli {
    /// Server URL (default: SEMORA_URL, config.toml, or the stored login)
    #[arg(long, global = true, value_name = "URL")]
    pub server: Option<String>,
    /// API key (default: SEMORA_API_KEY, then the stored login)
    #[arg(long, global = true, value_name = "KEY", hide_env_values = true)]
    pub api_key: Option<String>,
    /// Print JSON instead of text
    #[arg(long, global = true)]
    pub json: bool,
    #[command(subcommand)]
    pub cmd: Cmd,
}

#[derive(Debug, Subcommand)]
pub enum Cmd {
    /// Sign in through the browser and store an API key for this machine
    Login {
        /// Print the URL instead of opening a browser; paste the code shown after approval
        #[arg(long)]
        no_browser: bool,
        /// Name for the new key (default: cli@HOSTNAME)
        #[arg(long)]
        name: Option<String>,
    },
    /// Revoke and forget the stored key
    Logout,
    /// Show the server, user and key in use
    Whoami,
    /// Publish a file or folder; replaces the remembered document if there is one
    Push(PushArgs),
    /// Re-push whenever files change (debounced 500 ms)
    Watch(WatchArgs),
    /// Copy the project's documents from the server into this folder
    Pull(PullArgs),
    /// List what changed here and what is newer on the server
    Status {
        /// Only documents at this path or under it (default: the current folder)
        path: Option<PathBuf>,
    },
    /// Block until feedback arrives from the page
    Wait(WaitArgs),
    /// Show a message in the open page
    Say {
        /// Document id or URL
        doc: String,
        /// Text to show
        text: String,
        /// Event id this message answers
        #[arg(long)]
        reply_to: Option<i64>,
    },
    /// List or search the documents you can open
    Ls {
        /// Full-text search
        #[arg(short = 'q', long = "query")]
        query: Option<String>,
        /// Only documents with this tag
        #[arg(long)]
        tag: Option<String>,
        /// md, html or bundle
        #[arg(long)]
        kind: Option<String>,
        /// Only documents in this project (its id)
        #[arg(long)]
        project: Option<String>,
        #[arg(long, default_value_t = 50)]
        limit: u32,
    },
    /// Metadata, files and revisions of a document
    Show {
        /// Document id or URL
        doc: String,
    },
    /// Download raw content
    Get {
        /// Document id or URL
        doc: String,
        /// File inside a bundle (default: the entry page)
        path: Option<String>,
        /// Revision (default: latest)
        #[arg(long)]
        rev: Option<i64>,
        /// Write to a file instead of stdout
        #[arg(short = 'o', long = "output")]
        output: Option<PathBuf>,
    },
    /// Edit tags: `+add -remove` (a bare name adds)
    Tag {
        /// Document id or URL
        doc: String,
        #[arg(required = true, num_args = 1.., allow_hyphen_values = true)]
        changes: Vec<String>,
    },
    /// Delete a document
    Rm {
        /// Document id or URL
        doc: String,
    },
    /// Open a document in the browser
    Open {
        /// Document id or URL
        doc: String,
    },
    /// Manage your API keys
    #[command(subcommand)]
    Keys(KeysCmd),
    /// Make a folder a project, list your projects, and arrange a project's tree
    #[command(subcommand)]
    Project(ProjectCmd),
    /// Print a short usage guide to paste into an agent's instructions
    Skill,
}

#[derive(Debug, Args)]
pub struct PushArgs {
    /// File (.md or .html) or folder (becomes a bundle)
    pub path: PathBuf,
    /// Replace this document instead of the remembered one
    #[arg(long)]
    pub id: Option<String>,
    /// Always create a new document
    #[arg(long, conflicts_with = "id")]
    pub new: bool,
    #[arg(long)]
    pub title: Option<String>,
    /// Comma-separated tags (repeatable)
    #[arg(long = "tag", value_name = "TAGS")]
    pub tags: Vec<String>,
    /// Revision message
    #[arg(short = 'm', long = "message")]
    pub message: Option<String>,
    /// Entry page of a bundle
    #[arg(long)]
    pub entry: Option<String>,
    /// key=value stored on the document (repeatable)
    #[arg(long = "meta", value_name = "KEY=VALUE")]
    pub meta: Vec<String>,
    /// In a project: push even if the server has a newer revision than this folder pulled
    #[arg(long)]
    pub force: bool,
    /// After pushing, wait for feedback (same as `semora wait`)
    #[arg(long)]
    pub wait: bool,
    #[command(flatten)]
    pub wait_opts: WaitOpts,
}

#[derive(Debug, Args)]
pub struct PullArgs {
    /// Only documents at this path or under it (default: the current folder)
    pub path: Option<PathBuf>,
    /// Also overwrite files changed here since the last pull or push
    #[arg(long)]
    pub force: bool,
}

#[derive(Debug, Args, Clone, Default)]
pub struct WaitOpts {
    /// Seconds to wait (default: SEMORA_WAIT_TIMEOUT or 900)
    #[arg(long)]
    pub timeout: Option<u64>,
    /// Only these kinds: comment,drawing,page_event,message
    #[arg(long)]
    pub kinds: Option<String>,
    /// Keep streaming events as JSON lines instead of exiting
    #[arg(long)]
    pub follow: bool,
    /// Seconds to keep collecting after the first event
    #[arg(long, default_value_t = 3.0)]
    pub collect: f64,
    /// Folder for downloaded screenshots (default: a temp folder)
    #[arg(long)]
    pub save_dir: Option<PathBuf>,
}

#[derive(Debug, Args)]
pub struct WaitArgs {
    /// Document id, page URL or websocket URL
    pub doc: String,
    #[command(flatten)]
    pub opts: WaitOpts,
}

#[derive(Debug, Args)]
pub struct WatchArgs {
    /// File or folder to watch
    pub path: PathBuf,
    /// Document to replace (default: the remembered one)
    #[arg(long)]
    pub id: Option<String>,
    #[arg(long)]
    pub title: Option<String>,
    #[arg(long = "tag", value_name = "TAGS")]
    pub tags: Vec<String>,
    #[arg(long)]
    pub entry: Option<String>,
}

#[derive(Debug, Subcommand)]
pub enum KeysCmd {
    /// List your keys
    Ls,
    /// Create a key and print it once
    Create {
        name: String,
        /// Comma-separated scopes (default: all)
        #[arg(long)]
        scopes: Option<String>,
        /// Expire after this many days
        #[arg(long)]
        expires_in_days: Option<u32>,
    },
    /// Revoke a key by id
    Revoke { id: i64 },
}

#[derive(Debug, Subcommand)]
pub enum ProjectCmd {
    /// Make a folder a project: create it on the server and write .semora
    Init {
        /// The project folder (default: the current one)
        dir: Option<PathBuf>,
        /// The project id (default: made from the folder's name)
        #[arg(long)]
        name: Option<String>,
        /// Publish into a project someone else owns and you collaborate on
        #[arg(long)]
        join: bool,
    },
    /// List your projects
    Ls,
    /// Arrange how documents show in the project's tree (display only; links never change)
    Map(MapArgs),
}

#[derive(Debug, Args)]
pub struct MapArgs {
    /// Project id (default: the .semora file in this folder or a parent)
    #[arg(long, global = true)]
    pub project: Option<String>,
    #[command(subcommand)]
    pub cmd: MapCmd,
}

#[derive(Debug, Subcommand)]
pub enum MapCmd {
    /// List the rules in order
    Ls,
    /// Add a rule: documents matching PATTERN show inside FOLDER ("/" is the project root)
    Add {
        /// For example 'docs/**' (quote it, or the shell expands it)
        pattern: String,
        /// For example misc/ or / (use . if your shell rewrites a leading slash)
        folder: String,
        /// Put the rule at this number (1 is first); default: last
        #[arg(long, value_name = "N")]
        at: Option<usize>,
    },
    /// Remove rule number N (see `map ls`)
    Rm {
        /// The rule's number, from `map ls`
        n: usize,
    },
}