feat(cli): full command-line interface via clap

Replace the ad-hoc `--serve` arg sniff with a clap-parsed CLI:

- positional <file> to open at startup (also the OS file-association path)
- --new, --read-only, --backend, --safe-mode/--no-gpu
- --serve/--port (headless JSON automation, unchanged behaviour)
- --export IN OUT (headless format convert via the automation load/save infra)
- --script FILE (run command lines at startup)
- --log/RUST_LOG, plus --version/--help for free

GUI-only options pass to app::boot through a OnceLock config; read-only
gates the save paths. The web build skips parsing (no args).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-20 13:59:20 +03:00
1 parent 7e70a2a97c
commit 79bb1d67b1
8 files changed
+399 -30

No files matched your search

+23
View File
@@ -32,6 +32,29 @@ pub fn serve() {
}
}
/// Headless one-shot format conversion (`--export IN OUT`). Loads `input`,
/// writes `output` (format chosen from `output`'s extension), and returns a
/// process exit code (0 on success). No window is created.
pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 {
let doc = match crate::io::load_file(input) {
Ok(doc) => doc,
Err(e) => {
eprintln!("export: cannot read {}: {e}", input.display());
return 1;
}
};
match crate::io::save(&doc, output) {
Ok(()) => {
println!("Exported {} → {}", input.display(), output.display());
0
}
Err(e) => {
eprintln!("export: cannot write {}: {e}", output.display());
1
}
}
}
/// `--port <N>` if present on the command line.
fn port_arg() -> Option<u16> {
let mut args = std::env::args();
+39 -14
View File
@@ -1,7 +1,7 @@
#[cfg(not(target_arch = "wasm32"))]
mod automation;
#[cfg(not(target_arch = "wasm32"))]
pub use automation::serve;
pub use automation::{export_headless, serve};
mod cmd_result;
mod commands;
mod document;
@@ -332,6 +332,9 @@ pub(super) struct OpenCADStudio {
/// Whether the one-time default-association prompt has already been shown.
/// Persisted via [`settings::UserSettings`] so it survives restarts.
default_assoc_prompted: bool,
/// Read-only session (`--read-only`): editing is allowed but every save
/// path is refused. Set once at boot from the CLI config.
read_only: bool,
/// Tag of the latest available release (without the leading "v"),
/// e.g. `"0.3.0"`. `None` when up-to-date or check hasn't returned.
update_notice_version: Option<String>,
@@ -1594,6 +1597,7 @@ impl OpenCADStudio {
point_size_buf: String::new(),
point_size_relative: true,
default_assoc_prompted: false,
read_only: false,
update_notice_version: None,
update_notice_body: None,
clipboard: Vec::new(),
@@ -1834,18 +1838,32 @@ impl OpenCADStudio {
Message::UpdateCheckResult,
);
let focus_cmd = s.focus_cmd_input();
// Open a file path passed on the command line. Used by the
// OS file association: double-clicking a .dwg in the file
// explorer launches the binary with the file path as the
// first positional argument. Unknown / flag-style args are
// ignored. `Message::OpenRecent` does the existence check
// and reports a clean error if the path is bogus.
let cli_open: Task<Message> = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().to_string_lossy().starts_with('-'))
.map(|p| Task::done(Message::OpenRecent(p)))
.unwrap_or_else(Task::none);
// Startup configuration from the command line (see `cli`). A file
// argument — also how the OS file association launches us when a .dwg
// is double-clicked — opens via `OpenRecent`, which existence-checks
// the path and reports a clean error if it is bogus. `--new` opens a
// fresh drawing tab instead of the welcome screen. `--read-only`
// disables saving. `--script` queues command lines to run once up.
let cfg = crate::cli::gui_config();
s.read_only = cfg.read_only;
let cli_open: Task<Message> = if let Some(p) = cfg.file {
Task::done(Message::OpenRecent(p))
} else if cfg.new {
Task::done(Message::TabNew)
} else {
Task::none()
};
// Startup command script: each line dispatched as if typed at the
// command line, in order, after any file open is requested.
let script: Task<Message> = if cfg.script_lines.is_empty() {
Task::none()
} else {
Task::batch(
cfg.script_lines
.into_iter()
.map(|line| Task::done(Message::Command(line))),
)
};
// One-time prompt offering to make Open CAD Studio the default app for
// .dwg / .dxf. Shown only on the first launch that hasn't answered it
// yet; the flag is persisted so we never ask twice.
@@ -1855,7 +1873,14 @@ impl OpenCADStudio {
}
(
s,
Task::batch([open_main, check_update, focus_cmd, cli_open, assoc_prompt]),
Task::batch([
open_main,
check_update,
focus_cmd,
cli_open,
script,
assoc_prompt,
]),
)
}
+10
View File
@@ -947,6 +947,11 @@ impl OpenCADStudio {
Message::ObjImportPath(None) => Task::none(),
Message::SaveFile => {
if self.read_only {
self.command_line
.push_error("Read-only session (--read-only): saving is disabled.");
return Task::none();
}
let i = self.active_tab;
// Stamp the live grid/snap toggles onto the VPort so the file
// reflects them even if they came from settings with no
@@ -973,6 +978,11 @@ impl OpenCADStudio {
}
Message::SaveAs => {
if self.read_only {
self.command_line
.push_error("Read-only session (--read-only): saving is disabled.");
return Task::none();
}
let i = self.active_tab;
self.save_dialog_for_unsaved = false;
self.open_save_dialog_window(i)