Skip to main content

ashlar_cli/
lib.rs

1//! The standard verbs of an app's command-line tool, over its command registry (`<tool>` is the
2//! name given to [`Cli::new`], the command as it is installed):
3//!
4//! ```text
5//! <tool> commands [--json] [--query Q]          list commands
6//! <tool> describe <id>                          one command in full (parameter schema)
7//! <tool> docs [category|path.md] [--query Q]   the documentation: categories, one category, one file
8//! <tool> exec <id> [json|-]                     run one command
9//! <tool> run <file.jsonl|-> [--keep-going]    run {"command","params"} lines
10//! <tool> mcp [--isolate] [--per-command]        MCP server on stdio
11//! <tool> serve                                  control protocol on stdio (a worker for `mcp --isolate`)
12//! <tool> version [--json]
13//! ```
14//!
15//! `commands`, `describe`, `exec`, `run` and `mcp` take `--connect PORT` to drive the running app
16//! instead of a headless session. Replies are one line, `{"ok":true,"result":…}` or
17//! `{"ok":false,"error":"…"}`; exit codes are 0 (ok), 1 (the command failed), 2 (usage). Apps add
18//! their own verbs with [`Cli::verb`].
19//!
20//! ```no_run
21//! # fn session() -> Box<dyn ashlar_command::Dispatch> { unimplemented!() }
22//! fn main() -> std::process::ExitCode {
23//!     ashlar_cli::Cli::new("notes", env!("CARGO_PKG_VERSION"), session).run()
24//! }
25//! ```
26#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
27
28mod args;
29
30pub use args::Args;
31
32use std::io::{BufRead, Write};
33use std::path::PathBuf;
34
35use ashlar_command::{CommandInfo, Dispatch};
36use ashlar_control::{AppInfo, Client, DEFAULT_MAX_LINE, NoUi, Reply};
37use ashlar_mcp::{Backend, McpServer, ServerOptions, Subprocess, ToolMode};
38use serde_json::{Value, json};
39
40/// How a verb ended.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Exit {
43    Ok,
44    /// The command (or one of a run's) failed.
45    Failed,
46    /// Bad arguments.
47    Usage,
48}
49
50impl Exit {
51    pub fn code(self) -> u8 {
52        match self {
53            Exit::Ok => 0,
54            Exit::Failed => 1,
55            Exit::Usage => 2,
56        }
57    }
58}
59
60impl From<Exit> for std::process::ExitCode {
61    fn from(e: Exit) -> Self {
62        std::process::ExitCode::from(e.code())
63    }
64}
65
66/// The streams a verb uses (real stdio in [`Cli::run`], buffers in tests). `Send`, so `mcp` can
67/// read and write on their own threads.
68pub struct Io<'a> {
69    pub input: &'a mut (dyn BufRead + Send),
70    pub out: &'a mut (dyn Write + Send),
71    pub err: &'a mut (dyn Write + Send),
72}
73
74impl Io<'_> {
75    /// Print a reply line and turn it into an exit code.
76    pub fn reply(&mut self, r: &Reply) -> Exit {
77        let _ = writeln!(self.out, "{}", r.to_json(None));
78        if r.is_ok() { Exit::Ok } else { Exit::Failed }
79    }
80
81    /// A failure: with `json`, the reply line on stdout (as [`Io::reply`]); without it, one line
82    /// of plain text on stderr (`<tool>: <message>`), for a person reading a terminal.
83    pub fn fail(&mut self, json: bool, tool: &str, msg: impl std::fmt::Display) -> Exit {
84        if json {
85            return self.reply(&Reply::err(msg));
86        }
87        let _ = writeln!(self.err, "{tool}: {msg}");
88        Exit::Failed
89    }
90
91    /// A reply: with `json` the line on stdout; without it a success is the result as indented
92    /// JSON on stdout and an error is plain text on stderr (see [`Io::fail`]).
93    pub fn reply_as(&mut self, json: bool, tool: &str, r: &Reply) -> Exit {
94        match r {
95            _ if json => self.reply(r),
96            Reply::Ok(v) => {
97                let _ = writeln!(self.out, "{}", serde_json::to_string_pretty(v).unwrap_or_default());
98                Exit::Ok
99            }
100            Reply::Err { error, .. } => self.fail(false, tool, error),
101        }
102    }
103
104    /// A usage error: the reply line on stdout, a hint on stderr.
105    pub fn usage(&mut self, msg: impl std::fmt::Display, usage: &str) -> Exit {
106        let _ = writeln!(self.out, "{}", Reply::err(&msg).to_json(None));
107        let _ = writeln!(self.err, "{msg}\nusage: {usage}");
108        Exit::Usage
109    }
110}
111
112type SessionFn = Box<dyn FnMut() -> Box<dyn Dispatch>>;
113type VerbFn = Box<dyn FnMut(Args, &mut Io<'_>) -> Exit>;
114
115struct Verb {
116    name: String,
117    usage: String,
118    about: String,
119    run: VerbFn,
120}
121
122/// An app's CLI: standard verbs plus its own.
123pub struct Cli {
124    app: AppInfo,
125    session: SessionFn,
126    key_dir: Option<PathBuf>,
127    instructions: String,
128    docs: ashlar_mcp::Library,
129    user_docs: Option<PathBuf>,
130    prompts: Option<String>,
131    verbs: Vec<Verb>,
132}
133
134/// Where commands go: a headless session or the running app.
135enum Target {
136    Local(Box<dyn Dispatch>),
137    Remote(Client),
138}
139
140impl Target {
141    fn call(&mut self, method: &str, params: Value, app: &AppInfo) -> Reply {
142        match self {
143            Target::Local(d) => ashlar_control::answer(method, params, d.as_mut(), &mut NoUi, app),
144            Target::Remote(c) => c.request(method, params).unwrap_or_else(|e| Reply::err(format!("the app is not reachable: {e}"))),
145        }
146    }
147}
148
149const STANDARD: &[(&str, &str, &str)] = &[
150    ("commands", "commands [--json] [--query Q] [--connect PORT]", "list commands"),
151    ("describe", "describe <id> [--connect PORT]", "one command in full, with its parameter schema"),
152    (
153        "docs",
154        "docs [category|path.md] [--query Q] [--json] [--where] | docs save|append <path.md> [--file F] [--replace-builtin] | docs delete <path.md>",
155        "the documentation: categories, a category, or a file verbatim; save, append to or delete a page of your own",
156    ),
157    ("exec", "exec <id> [json|-] [--connect PORT]", "run one command (params as JSON, `-` reads stdin)"),
158    ("run", "run <file.jsonl|-> [--keep-going] [--connect PORT]", "run {\"command\",\"params\"} lines, one reply each"),
159    ("mcp", "mcp [--connect PORT | --isolate] [--per-command]", "MCP server on stdio"),
160    ("serve", "serve", "control protocol on stdio (a worker for `mcp --isolate`)"),
161    ("version", "version [--json]", "print the version"),
162];
163
164impl Cli {
165    /// `session` makes a fresh headless session (one per invocation).
166    pub fn new(name: impl Into<String>, version: impl Into<String>, session: impl FnMut() -> Box<dyn Dispatch> + 'static) -> Self {
167        Self {
168            app: AppInfo::new(name, version),
169            session: Box::new(session),
170            key_dir: None,
171            instructions: String::new(),
172            docs: ashlar_mcp::Library::new(),
173            user_docs: None,
174            prompts: None,
175            verbs: Vec::new(),
176        }
177    }
178
179    /// Where the running app writes its control key (its settings folder); enables `--connect`.
180    pub fn key_dir(mut self, dir: impl Into<PathBuf>) -> Self {
181        self.key_dir = Some(dir.into());
182        self
183    }
184
185    /// The MCP server's instructions (what the app is, how to drive it).
186    pub fn instructions(mut self, text: impl Into<String>) -> Self {
187        self.instructions = text.into();
188        self
189    }
190
191    /// The documentation library (`docs`, and the MCP server's tools and resources), usually
192    /// the app's built-in pages.
193    pub fn docs(mut self, library: ashlar_mcp::Library) -> Self {
194        self.docs = library;
195        self
196    }
197
198    /// The user's own documentation folder, read on top of [`Self::docs`] whenever they are
199    /// used (same path: the user's page wins). `docs --where` prints it; it is never created.
200    pub fn user_docs(mut self, dir: impl Into<PathBuf>) -> Self {
201        self.user_docs = Some(dir.into());
202        self
203    }
204
205    /// The documentation category `mcp` serves as prompts ([`McpServer::prompts`]).
206    pub fn prompts(mut self, category: impl Into<String>) -> Self {
207        self.prompts = Some(category.into());
208        self
209    }
210
211    /// Add an app-specific verb (or replace a standard one of the same name).
212    pub fn verb(mut self, name: &str, usage: &str, about: &str, run: impl FnMut(Args, &mut Io<'_>) -> Exit + 'static) -> Self {
213        self.verbs.retain(|v| v.name != name);
214        self.verbs.push(Verb { name: name.into(), usage: usage.into(), about: about.into(), run: Box::new(run) });
215        self
216    }
217
218    /// Run with the process arguments and real stdio.
219    pub fn run(mut self) -> std::process::ExitCode {
220        let args: Vec<String> = std::env::args().skip(1).collect();
221        // the handles, not their locks: those cannot move to another thread
222        let (mut i, mut o, mut e) = (std::io::BufReader::new(std::io::stdin()), std::io::stdout(), std::io::stderr());
223        self.run_with(args, &mut Io { input: &mut i, out: &mut o, err: &mut e }).into()
224    }
225
226    /// Run one invocation (`args` without the program name).
227    pub fn run_with(&mut self, args: Vec<String>, io: &mut Io<'_>) -> Exit {
228        let mut it = args.into_iter();
229        let Some(verb) = it.next() else {
230            self.help(io.err);
231            return Exit::Usage;
232        };
233        let args = Args::new(it);
234        if let Some(v) = self.verbs.iter_mut().find(|v| v.name == verb) {
235            return (v.run)(args, io);
236        }
237        match verb.as_str() {
238            "commands" => self.commands(args, io),
239            "describe" => self.describe(args, io),
240            "docs" => self.docs_verb(args, io),
241            "exec" => self.exec(args, io),
242            "run" => self.run_lines(args, io),
243            "mcp" => self.mcp(args, io),
244            "serve" => self.serve(args, io),
245            "version" | "--version" | "-V" => self.version(args, io),
246            "help" | "--help" | "-h" => {
247                self.help(io.out);
248                Exit::Ok
249            }
250            other => {
251                let _ = writeln!(io.err, "unknown command `{other}`\n");
252                self.help(io.err);
253                Exit::Usage
254            }
255        }
256    }
257
258    fn help(&self, out: &mut dyn Write) {
259        let name = &self.app.name;
260        let _ = writeln!(out, "{name} {}\n\nusage:", self.app.version);
261        for (verb, usage, about) in STANDARD {
262            if !self.verbs.iter().any(|v| v.name == *verb) {
263                let _ = writeln!(out, "  {name} {usage:<52} {about}");
264            }
265        }
266        for v in &self.verbs {
267            let _ = writeln!(out, "  {name} {:<52} {}", v.usage, v.about);
268        }
269        let _ = writeln!(
270            out,
271            "\nReplies are one JSON line {{\"ok\":…}} (`commands`, `docs` and `version` print text unless --json, and their errors as text on stderr); exit codes 0 ok, 1 failed, 2 usage."
272        );
273    }
274
275    fn usage_of(verb: &str) -> &'static str {
276        STANDARD.iter().find(|s| s.0 == verb).map_or("", |s| s.1)
277    }
278
279    /// The target for `--connect PORT`, or a fresh headless session.
280    fn target(&mut self, connect: Option<u16>) -> Result<Target, String> {
281        match connect {
282            Some(port) => {
283                let dir = self.key_dir.as_ref().ok_or("this app's CLI cannot connect (no key folder configured)")?;
284                Client::connect(port, dir).map(Target::Remote).map_err(|e| e.to_string())
285            }
286            None => Ok(Target::Local((self.session)())),
287        }
288    }
289
290    fn commands(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
291        let usage = Self::usage_of("commands");
292        let json = args.flag("--json");
293        let query = match args.value("--query") {
294            Ok(q) => q.unwrap_or_default(),
295            Err(e) => return io.usage(e, usage),
296        };
297        let connect = args.number::<u16>("--connect");
298        if let Err(e) = args.finish() {
299            return io.usage(e, usage);
300        }
301        let connect = match connect {
302            Ok(c) => c,
303            Err(e) => return io.usage(e, usage),
304        };
305        let name = self.app.name.clone();
306        let mut target = match self.target(connect) {
307            Ok(t) => t,
308            Err(e) => return io.fail(json, &name, e),
309        };
310        let r = target.call("engine.commands", json!({"query": query}), &self.app);
311        if json {
312            return io.reply(&r);
313        }
314        match r {
315            Reply::Ok(v) => {
316                let list: Vec<CommandInfo> = serde_json::from_value(v).unwrap_or_default();
317                let width = list.iter().map(|c| c.id.chars().count()).max().unwrap_or(0);
318                for c in &list {
319                    let mut line = format!("{:<width$}  {}", c.id, c.label);
320                    if let Some(s) = &c.shortcut {
321                        line.push_str(&format!("  [{s}]"));
322                    }
323                    if !c.enabled {
324                        line.push_str(&format!("  (disabled: {})", c.disabled_reason.as_deref().unwrap_or("not available")));
325                    }
326                    let _ = writeln!(io.out, "{}", line.trim_end());
327                }
328                Exit::Ok
329            }
330            e => io.reply_as(false, &name, &e),
331        }
332    }
333
334    fn describe(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
335        let usage = Self::usage_of("describe");
336        // Options first: `--connect PORT` must not lose its value to a positional.
337        let connect = args.number::<u16>("--connect");
338        let id = args.positional();
339        if let Err(e) = args.finish() {
340            return io.usage(e, usage);
341        }
342        let Some(id) = id else { return io.usage("describe needs a command id", usage) };
343        let connect = match connect {
344            Ok(c) => c,
345            Err(e) => return io.usage(e, usage),
346        };
347        let mut target = match self.target(connect) {
348            Ok(t) => t,
349            Err(e) => return io.reply(&Reply::err(e)),
350        };
351        let r = match target.call("engine.commands", json!({}), &self.app) {
352            Reply::Ok(v) => {
353                let list: Vec<CommandInfo> = serde_json::from_value(v).unwrap_or_default();
354                match list.iter().find(|c| c.id == id) {
355                    Some(c) => Reply::ok(serde_json::to_value(c).unwrap_or_default()),
356                    None => {
357                        let hint = ashlar_command::did_you_mean_command(&id, &list).map(|h| format!("; {h}")).unwrap_or_default();
358                        Reply::err(format!("no command `{id}`{hint} (see `{} commands`)", self.app.name))
359                    }
360                }
361            }
362            e => e,
363        };
364        io.reply(&r)
365    }
366
367    fn exec(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
368        let usage = Self::usage_of("exec");
369        let connect = args.number::<u16>("--connect");
370        let id = args.positional();
371        let raw = args.positional();
372        if let Err(e) = args.finish() {
373            return io.usage(e, usage);
374        }
375        let Some(id) = id else { return io.usage("exec needs a command id", usage) };
376        let text = match raw.as_deref() {
377            None => String::new(),
378            Some("-") => {
379                let mut s = String::new();
380                if let Err(e) = io.input.read_to_string(&mut s) {
381                    return io.usage(format!("could not read parameters from stdin: {e}"), usage);
382                }
383                s
384            }
385            Some(t) => t.to_string(),
386        };
387        let params = if text.trim().is_empty() {
388            json!({})
389        } else {
390            match serde_json::from_str::<Value>(&text) {
391                Ok(v) => v,
392                Err(e) => return io.usage(format!("parameters are not valid JSON: {e}"), usage),
393            }
394        };
395        let connect = match connect {
396            Ok(c) => c,
397            Err(e) => return io.usage(e, usage),
398        };
399        let mut target = match self.target(connect) {
400            Ok(t) => t,
401            Err(e) => return io.reply(&Reply::err(e)),
402        };
403        let r = target.call("engine.execute", json!({"command": id, "params": params}), &self.app);
404        io.reply(&r)
405    }
406
407    fn run_lines(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
408        let usage = Self::usage_of("run");
409        let keep_going = args.flag("--keep-going");
410        let connect = args.number::<u16>("--connect");
411        let file = args.positional();
412        if let Err(e) = args.finish() {
413            return io.usage(e, usage);
414        }
415        let Some(file) = file else { return io.usage("run needs a file (or `-` for stdin)", usage) };
416        let text = if file == "-" {
417            let mut s = String::new();
418            match io.input.read_to_string(&mut s) {
419                Ok(_) => s,
420                Err(e) => return io.usage(format!("could not read stdin: {e}"), usage),
421            }
422        } else {
423            match std::fs::read_to_string(&file) {
424                Ok(s) => s,
425                Err(e) => return io.usage(format!("could not read {file}: {e}"), usage),
426            }
427        };
428        let connect = match connect {
429            Ok(c) => c,
430            Err(e) => return io.usage(e, usage),
431        };
432        let mut target = match self.target(connect) {
433            Ok(t) => t,
434            Err(e) => return io.reply(&Reply::err(e)),
435        };
436        let mut exit = Exit::Ok;
437        for (n, line) in text.lines().enumerate() {
438            let line = line.trim();
439            if line.is_empty() || line.starts_with('#') {
440                continue;
441            }
442            let r = match serde_json::from_str::<Value>(line) {
443                Ok(v) => match v.get("command").and_then(Value::as_str) {
444                    Some(id) => {
445                        let params = v.get("params").cloned().unwrap_or(json!({}));
446                        target.call("engine.execute", json!({"command": id, "params": params}), &self.app)
447                    }
448                    None => Reply::err(format!("line {}: expected {{\"command\": \"…\", \"params\": {{…}}}}", n.saturating_add(1))),
449                },
450                Err(e) => Reply::err(format!("line {}: bad JSON: {e}", n.saturating_add(1))),
451            };
452            if io.reply(&r) == Exit::Failed {
453                exit = Exit::Failed;
454                if !keep_going {
455                    break;
456                }
457            }
458        }
459        exit
460    }
461
462    fn mcp(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
463        let usage = Self::usage_of("mcp");
464        let isolate = args.flag("--isolate");
465        let per_command = args.flag("--per-command");
466        let connect = args.number::<u16>("--connect");
467        if let Err(e) = args.finish() {
468            return io.usage(e, usage);
469        }
470        let backend = match connect {
471            Err(e) => return io.usage(e, usage),
472            Ok(Some(_)) if isolate => return io.usage("--connect and --isolate exclude each other", usage),
473            Ok(Some(port)) => {
474                let Some(dir) = &self.key_dir else { return io.usage("this app's CLI cannot connect (no key folder configured)", usage) };
475                match Client::connect(port, dir) {
476                    Ok(c) => Backend::Remote(c),
477                    Err(e) => {
478                        let _ = writeln!(io.err, "{e}");
479                        return Exit::Failed;
480                    }
481                }
482            }
483            Ok(None) if isolate => match std::env::current_exe() {
484                Ok(exe) => Backend::Subprocess(Subprocess::new(exe, ["serve"])),
485                Err(e) => {
486                    let _ = writeln!(io.err, "cannot find this program to start workers: {e}");
487                    return Exit::Failed;
488                }
489            },
490            Ok(None) => Backend::Headless((self.session)()),
491        };
492        let mut opts = ServerOptions::new(&self.app.name, &self.app.version).instructions(&self.instructions);
493        if per_command {
494            opts = opts.tools(ToolMode::PerCommand);
495        }
496        let mut server = McpServer::new(backend, opts).docs(self.library());
497        if let Some(dir) = &self.user_docs {
498            server = server.user_docs(dir.clone());
499        }
500        if let Some(c) = &self.prompts {
501            server = server.prompts(c.clone());
502        }
503        // stdout carries only the protocol; problems go to stderr.
504        match server.serve(&mut *io.input, &mut *io.out) {
505            Ok(()) => Exit::Ok,
506            Err(e) => {
507                let _ = writeln!(io.err, "mcp: {e}");
508                Exit::Failed
509            }
510        }
511    }
512
513    /// The built-in pages with the user's folder on top.
514    fn library(&self) -> ashlar_mcp::Library {
515        match &self.user_docs {
516            Some(dir) => self.docs.clone().overlay_dir(dir),
517            None => self.docs.clone(),
518        }
519    }
520
521    fn docs_verb(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
522        use ashlar_mcp::docs::{category_json, doc_json, node_json};
523        let usage = Self::usage_of("docs");
524        let json = args.flag("--json");
525        let place = args.flag("--where");
526        let query = match args.value("--query") {
527            Ok(q) => q,
528            Err(e) => return io.usage(e, usage),
529        };
530        let file = match args.value("--file") {
531            Ok(f) => f,
532            Err(e) => return io.usage(e, usage),
533        };
534        let replace = args.flag("--replace-builtin");
535        let target = args.positional();
536        let write = target.as_deref().filter(|t| matches!(*t, "save" | "append" | "delete")).map(str::to_string);
537        let page = if write.is_some() { args.positional() } else { None };
538        if let Err(e) = args.finish() {
539            return io.usage(e, usage);
540        }
541        if let Some(verb) = write {
542            return self.docs_write(&verb, page, file, replace, json, io);
543        }
544        if file.is_some() || replace {
545            return io.usage("--file and --replace-builtin go with `docs save` / `docs append`", usage);
546        }
547        let name = self.app.name.clone();
548        if place {
549            return match &self.user_docs {
550                Some(dir) if json => io.reply(&Reply::ok(json!({"path": dir.display().to_string(), "exists": dir.is_dir()}))),
551                Some(dir) => {
552                    let _ = writeln!(io.out, "{}", dir.display());
553                    if !dir.is_dir() {
554                        let _ = writeln!(io.err, "(not created yet: make it and add .md files, in folders for categories, to add your own pages)");
555                    }
556                    Exit::Ok
557                }
558                None => io.fail(json, &name, format!("{name} has no folder for your own documentation")),
559            };
560        }
561        let lib = self.library();
562        let target = target.map(|t| t.trim().trim_matches('/').to_string());
563        // a file, verbatim
564        if let Some(t) = target.as_deref().filter(|t| t.ends_with(".md") || t.starts_with("docs://")) {
565            return match lib.read(t) {
566                Ok(text) if json => {
567                    let doc =
568                        lib.docs().iter().find(|d| d.path == t.trim_start_matches("docs://")).map(doc_json).unwrap_or_else(|| json!({"path": t}));
569                    let mut v = doc;
570                    if let Some(o) = v.as_object_mut() {
571                        o.insert("text".into(), json!(text));
572                    }
573                    io.reply(&Reply::ok(v))
574                }
575                Ok(text) => {
576                    let _ = write!(io.out, "{text}");
577                    Exit::Ok
578                }
579                Err(e) => io.fail(json, &name, format!("{e} (see `{name} docs`)")),
580            };
581        }
582        let category = target.unwrap_or_default();
583        let Some(cat) = lib.category(&category) else {
584            let close = lib.close_matches(&category);
585            let hint = if close.is_empty() { String::new() } else { format!("; did you mean {}?", close.join(", ")) };
586            return io.fail(json, &name, format!("no category `{category}`{hint} (see `{name} docs`)"));
587        };
588        if let Some(q) = query.as_deref() {
589            let hits = lib.list(Some(&cat.path), Some(q), true);
590            if json {
591                return io.reply(&Reply::ok(json!({"docs": hits.into_iter().map(doc_json).collect::<Vec<_>>()})));
592            }
593            if hits.is_empty() {
594                let _ = writeln!(io.out, "nothing matches `{q}`");
595            }
596            write_docs(io.out, &hits);
597            return Exit::Ok;
598        }
599        if cat.path.is_empty() {
600            let Some(tree) = lib.tree("") else { return Exit::Ok };
601            if json {
602                return io.reply(&Reply::ok(node_json(&tree)));
603            }
604            let _ = writeln!(io.out, "{}: {} documents", tree.title, tree.total);
605            if !tree.description.is_empty() {
606                let _ = writeln!(io.out, "{}", tree.description);
607            }
608            let _ = writeln!(io.out);
609            write_tree(io.out, &tree.children, 1);
610            write_docs(io.out, &lib.list(Some(""), None, false));
611            let _ = writeln!(io.out, "\n`{name} docs <category>` shows one, `{name} docs <path.md>` prints a file, `--query Q` searches.");
612            return Exit::Ok;
613        }
614        if json {
615            return io.reply(&Reply::ok(category_json(&lib, &cat)));
616        }
617        let _ = writeln!(io.out, "{} ({}/)", cat.title, cat.path);
618        if !cat.overview.is_empty() {
619            let _ = writeln!(io.out, "\n{}", cat.overview);
620        }
621        if let Some(node) = lib.tree(&cat.path)
622            && !node.children.is_empty()
623        {
624            let _ = writeln!(io.out, "\nSubcategories:");
625            write_tree(io.out, &node.children, 1);
626        }
627        let docs = lib.list(Some(&cat.path), None, false);
628        if !docs.is_empty() {
629            let _ = writeln!(io.out, "\nDocuments:");
630            write_docs(io.out, &docs);
631        }
632        Exit::Ok
633    }
634
635    /// `docs save|append|delete <path>`: write the user's own pages (text from `--file` or stdin).
636    fn docs_write(&mut self, verb: &str, page: Option<String>, file: Option<String>, replace: bool, json: bool, io: &mut Io<'_>) -> Exit {
637        let usage = Self::usage_of("docs");
638        let name = self.app.name.clone();
639        let Some(dir) = self.user_docs.clone() else { return io.fail(json, &name, format!("{name} has no folder for your own documentation")) };
640        let Some(page) = page else { return io.usage(format!("docs {verb} needs a page path, e.g. memory/style.md"), usage) };
641        let text = if verb == "delete" {
642            String::new()
643        } else {
644            let read = match &file {
645                Some(f) if f != "-" => std::fs::read_to_string(f).map_err(|e| format!("could not read {f}: {e}")),
646                _ => {
647                    let mut s = String::new();
648                    io.input.read_to_string(&mut s).map(|_| s).map_err(|e| format!("could not read stdin: {e}"))
649                }
650            };
651            match read {
652                Ok(t) => t,
653                Err(e) => return io.usage(e, usage),
654            }
655        };
656        let built_in = self.docs.built_in_only();
657        let result = match verb {
658            "save" => ashlar_mcp::docs::write::save(&dir, &built_in, &page, &text, replace),
659            "append" => ashlar_mcp::docs::write::append(&dir, &page, &text),
660            _ => ashlar_mcp::docs::write::delete(&dir, &built_in, &page),
661        };
662        match result {
663            Ok(w) if json => io.reply(&Reply::ok(json!({"path": w.path, "file": w.file.display().to_string(), "created": w.created,
664                "bytes": w.bytes, "replacesBuiltIn": w.replaces_built_in}))),
665            Ok(w) => {
666                let done = match verb {
667                    "delete" => "deleted",
668                    _ if w.created => "created",
669                    _ => "saved",
670                };
671                let _ = writeln!(io.out, "{done} {} ({})", w.path, w.file.display());
672                Exit::Ok
673            }
674            Err(e) => io.fail(json, &name, e),
675        }
676    }
677
678    fn serve(&mut self, args: Args, io: &mut Io<'_>) -> Exit {
679        if let Err(e) = args.finish() {
680            return io.usage(e, Self::usage_of("serve"));
681        }
682        let mut session = (self.session)();
683        let app = self.app.clone();
684        ashlar_control::serve_stream(&mut *io.input, &mut *io.out, DEFAULT_MAX_LINE, |method, params| {
685            ashlar_control::answer(method, params, session.as_mut(), &mut NoUi, &app)
686        });
687        Exit::Ok
688    }
689
690    fn version(&mut self, mut args: Args, io: &mut Io<'_>) -> Exit {
691        let json = args.flag("--json");
692        if let Err(e) = args.finish() {
693            return io.usage(e, Self::usage_of("version"));
694        }
695        if json {
696            return io.reply(&Reply::ok(json!({"name": self.app.name, "version": self.app.version})));
697        }
698        let _ = writeln!(io.out, "{} {}", self.app.name, self.app.version);
699        Exit::Ok
700    }
701}
702
703/// Categories as an indented list: path, title, documents (in it and below).
704fn write_tree(out: &mut dyn Write, nodes: &[ashlar_mcp::docs::Node], depth: usize) {
705    for n in nodes {
706        let path = format!("{}{}/", "  ".repeat(depth), n.path);
707        let _ = writeln!(out, "{path:<36} {} ({})", n.title, n.total);
708        write_tree(out, &n.children, depth + 1);
709    }
710}
711
712/// Documents: path and title, with the description under it; the user's own marked.
713fn write_docs(out: &mut dyn Write, docs: &[&ashlar_mcp::docs::Doc]) {
714    for d in docs {
715        let mine = if d.source == ashlar_mcp::docs::Source::User { "  [yours]" } else { "" };
716        let _ = writeln!(out, "  {}  {}{mine}", d.path, d.title);
717        if !d.description.is_empty() {
718            let _ = writeln!(out, "      {}", d.description);
719        }
720    }
721}