1#![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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Exit {
43 Ok,
44 Failed,
46 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
66pub 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 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 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 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 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
122pub 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
134enum 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 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 pub fn key_dir(mut self, dir: impl Into<PathBuf>) -> Self {
181 self.key_dir = Some(dir.into());
182 self
183 }
184
185 pub fn instructions(mut self, text: impl Into<String>) -> Self {
187 self.instructions = text.into();
188 self
189 }
190
191 pub fn docs(mut self, library: ashlar_mcp::Library) -> Self {
194 self.docs = library;
195 self
196 }
197
198 pub fn user_docs(mut self, dir: impl Into<PathBuf>) -> Self {
201 self.user_docs = Some(dir.into());
202 self
203 }
204
205 pub fn prompts(mut self, category: impl Into<String>) -> Self {
207 self.prompts = Some(category.into());
208 self
209 }
210
211 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 pub fn run(mut self) -> std::process::ExitCode {
220 let args: Vec<String> = std::env::args().skip(1).collect();
221 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 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 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 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 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 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 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 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
703fn 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
712fn 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}