1use crate::BuildStep;
12use crate::glue::program;
13use bern_ir::Application;
14use bern_media::media_catalog;
15use bern_protocol::{CommandSpec, Retry, Type, TypeTable, session_catalog};
16use std::fmt::Write as _;
17use std::path::{Path, PathBuf};
18
19pub const DIR: &str = "skills";
21
22const DESCRIPTION_LIMIT: usize = 1024;
24
25pub fn path(app: &Application) -> PathBuf {
28 PathBuf::from(DIR).join(program(app)).join("SKILL.md")
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Place {
35 pub cli: String,
37 pub mcp: String,
39 pub installed: Option<String>,
42 pub connect: Option<String>,
45}
46
47impl Place {
48 pub fn beside_bin(app: &Application, windows: bool) -> Place {
51 let program = program(app);
52 let (separator, suffix) = if windows { ("\\", ".exe") } else { ("/", "") };
53 let path = |name: &str| ["..", "..", "bin", &format!("{name}{suffix}")].join(separator);
54 Place {
55 cli: path(&program),
56 mcp: path(&format!("{program}-mcp")),
57 installed: None,
58 connect: None,
59 }
60 }
61
62 pub fn installed(mut self, path: impl Into<String>) -> Place {
63 self.installed = Some(path.into());
64 self
65 }
66
67 pub fn connect(mut self, text: impl Into<String>) -> Place {
68 self.connect = Some(text.into());
69 self
70 }
71}
72
73pub fn steps(app: &Application, root: &Path, place: Option<&Place>) -> Vec<BuildStep> {
77 let mut steps = vec![BuildStep::RemoveIfExists {
78 path: root.join(DIR),
79 }];
80 if let Some(place) = place {
81 steps.push(BuildStep::Write {
82 path: root.join(path(app)),
83 contents: markdown(app, place).into_bytes(),
84 });
85 }
86 steps
87}
88
89pub fn listens(channels: &[String]) -> bool {
91 channels.iter().any(|channel| channel == "ipc")
92}
93
94pub fn android_connect(app: &Application) -> String {
97 let id = &app.app.bundle_id;
98 let dir = format!(
99 "bern-{:016x}",
100 bern_protocol::fingerprint_bytes(id.as_bytes())
101 );
102 format!(
103 "The app runs on the Android device and the programs run on this computer, which reaches the device through `adb`. `bern run --target android` forwards the control channel; when the app was started otherwise, forward it first:\n\n\
104 ```bash\n\
105 dir=\"${{BERN_IPC_DIR:-${{TMPDIR:-/tmp}}}}/{dir}\"\n\
106 mkdir -p \"$dir\" && chmod 700 \"$dir\" && rm -f \"$dir/android.sock\"\n\
107 adb forward localfilesystem:\"$dir/android.sock\" localabstract:bern.{id}\n\
108 ```\n\n\
109 On Windows the folder is `%TEMP%\\{dir}`."
110 )
111}
112
113pub fn simulator_connect(app: &Application, system: &str) -> String {
117 let id = &app.app.bundle_id;
118 format!(
119 "The app runs in the simulator and listens only when it was started with `BERN_IPC_DIR` naming the directory the programs use. `bern run` starts it so; otherwise launch it with:\n\n\
120 ```bash\n\
121 SIMCTL_CHILD_BERN_IPC_DIR=\"${{BERN_IPC_DIR:-${{TMPDIR:-/tmp}}}}\" xcrun simctl launch booted {id}\n\
122 ```\n\n\
123 When this Mac runs the macOS build of the same app too, both answer: choose the simulator's with `--engine {system}`."
124 )
125}
126
127fn one_line(text: &str) -> String {
129 text.split_whitespace().collect::<Vec<_>>().join(" ")
130}
131
132fn cell(text: &str) -> String {
134 one_line(text).replace('|', "\\|")
135}
136
137fn yaml_string(text: &str) -> String {
139 serde_json::to_string(text).expect("strings serialize")
140}
141
142fn type_text(ty: &Type, types: &TypeTable) -> String {
145 match ty {
146 Type::Enum { name } => match types.enum_def(name) {
147 Some(def) if def.is_unit_only() => def
148 .variants
149 .iter()
150 .map(|variant| variant.name.as_str())
151 .collect::<Vec<_>>()
152 .join("|"),
153 _ => ty.display(),
154 },
155 Type::Optional { inner } => type_text(inner, types),
156 other => other.display(),
157 }
158}
159
160fn names(app: &Application) -> Vec<String> {
162 let name = &app.app.name;
163 let mut names: Vec<String> = Vec::new();
164 let languages = std::iter::once(&app.languages.default).chain(&app.languages.supported);
165 for text in languages
166 .filter_map(|language| name.get(language))
167 .chain(name.translations.values().map(String::as_str))
168 {
169 let text = one_line(text);
170 if !text.is_empty() && !names.contains(&text) {
171 names.push(text);
172 }
173 }
174 if names.is_empty() {
175 names.push(program(app));
176 }
177 names
178}
179
180fn description(app: &Application, names: &[String], cli: &str) -> String {
183 let mut text = format!(
184 "Operate {} ({}) while it runs, through its command line `{cli}` and its MCP server `{cli}-mcp`: every command, query, state field and UI action of this build, with their parameters. Use when asked to use, inspect, test or automate {}.",
185 names.join(" / "),
186 app.app.bundle_id,
187 names[0],
188 );
189 if text.chars().count() > DESCRIPTION_LIMIT {
190 text = text.chars().take(DESCRIPTION_LIMIT - 1).collect();
191 text.push('…');
192 return text;
193 }
194 let room = DESCRIPTION_LIMIT - text.chars().count();
196 let mut listed: Vec<String> = Vec::new();
197 let mut length = " Commands: .".chars().count();
198 let mut all = true;
199 for spec in &app.protocol.commands {
200 let command = spec.external.cli.join(" ");
201 let added = command.chars().count() + if listed.is_empty() { 0 } else { 2 };
202 if length + added + 3 > room {
204 all = false;
205 break;
206 }
207 length += added;
208 listed.push(command);
209 }
210 if !listed.is_empty() {
211 text.push_str(" Commands: ");
212 text.push_str(&listed.join(", "));
213 if !all {
214 text.push_str(", …");
215 }
216 text.push('.');
217 }
218 text
219}
220
221struct Types<'a> {
224 seen: Vec<(String, &'a TypeTable)>,
225}
226
227impl<'a> Types<'a> {
228 fn add(&mut self, ty: &Type, types: &'a TypeTable) {
229 match ty {
230 Type::Optional { inner } | Type::List { inner } => self.add(inner, types),
231 Type::Enum { name } | Type::Record { name } => {
232 if self.seen.iter().any(|(seen, _)| seen == name) {
233 return;
234 }
235 self.seen.push((name.clone(), types));
236 if let Some(def) = types.record_def(name) {
237 for field in &def.fields {
238 self.add(&field.ty, types);
239 }
240 }
241 if let Some(def) = types.enum_def(name) {
242 for variant in &def.variants {
243 for field in &variant.fields {
244 self.add(&field.ty, types);
245 }
246 }
247 }
248 }
249 _ => {}
250 }
251 }
252
253 fn add_spec(&mut self, spec: &CommandSpec, types: &'a TypeTable) {
254 for param in &spec.params {
255 self.add(¶m.ty, types);
256 }
257 self.add(&spec.result, types);
258 }
259}
260
261fn common_errors(specs: &[CommandSpec]) -> Vec<String> {
264 let Some((first, rest)) = specs.split_first() else {
265 return Vec::new();
266 };
267 if rest.is_empty() {
268 return Vec::new();
269 }
270 first
271 .errors
272 .iter()
273 .filter(|code| rest.iter().all(|spec| spec.errors.contains(code)))
274 .cloned()
275 .collect()
276}
277
278fn codes(errors: &[String]) -> String {
279 errors
280 .iter()
281 .map(|code| format!("`{code}`"))
282 .collect::<Vec<_>>()
283 .join(", ")
284}
285
286fn command(
287 out: &mut String,
288 cli: &str,
289 spec: &CommandSpec,
290 types: &TypeTable,
291 session: bool,
292 common: &[String],
293) {
294 let _ = writeln!(out, "### `{cli} {}`\n", spec.external.cli.join(" "));
295 let doc = spec.doc.trim();
296 if !doc.is_empty() {
297 let doc: Vec<&str> = doc.lines().map(str::trim).collect();
298 let _ = writeln!(out, "{}\n", doc.join("\n"));
299 }
300 for param in &spec.params {
301 let optional = matches!(param.ty, Type::Optional { .. });
302 let _ = write!(
303 out,
304 "- `--{} <{}>`",
305 param.name.replace('_', "-"),
306 type_text(¶m.ty, types)
307 );
308 if optional {
309 out.push_str(" (optional; `none` for no value)");
310 }
311 let doc = one_line(¶m.doc);
312 if !doc.is_empty() {
313 let _ = write!(out, ": {doc}");
314 }
315 out.push('\n');
316 }
317 if !spec.params.is_empty() {
318 out.push('\n');
319 }
320 let mut facts = Vec::new();
321 if spec.result != Type::Unit {
322 facts.push(format!("Result: `{}`.", spec.result.display()));
323 }
324 let errors: Vec<String> = spec
325 .errors
326 .iter()
327 .filter(|code| !common.contains(code))
328 .cloned()
329 .collect();
330 if !errors.is_empty() {
331 let also = if common.is_empty() { "" } else { "also " };
332 facts.push(format!("Errors: {also}{}.", codes(&errors)));
333 }
334 if let Some(event) = &spec.on {
335 facts.push(format!("Also runs by itself after the event `{event}`."));
336 }
337 if spec.policy.retry == Retry::Idempotent && !spec.is_query() {
338 facts.push("Repeating it leaves the same state.".into());
339 }
340 let mcp = if session {
341 format!(
342 "MCP tool: `{}` (with an optional `session_id`).",
343 spec.external.mcp
344 )
345 } else {
346 format!("MCP tool: `{}`.", spec.external.mcp)
347 };
348 facts.push(mcp);
349 let _ = writeln!(out, "{}\n", facts.join(" "));
350}
351
352fn type_section(out: &mut String, name: &str, types: &TypeTable) {
353 if let Some(def) = types.record_def(name) {
354 let _ = writeln!(
355 out,
356 "### `{name}`\n\nA JSON object with every one of these fields:\n\n| Field | Type | Meaning |\n| --- | --- | --- |"
357 );
358 for field in &def.fields {
359 let _ = writeln!(
360 out,
361 "| `{}` | `{}` | {} |",
362 field.name,
363 field.ty.display(),
364 cell(&field.doc)
365 );
366 }
367 out.push('\n');
368 } else if let Some(def) = types.enum_def(name) {
369 if def.is_unit_only() {
370 let variants: Vec<String> = def
371 .variants
372 .iter()
373 .map(|variant| format!("`\"{}\"`", variant.name))
374 .collect();
375 let _ = writeln!(out, "### `{name}`\n\nOne of {}.\n", variants.join(", "));
376 } else {
377 let _ = writeln!(
378 out,
379 "### `{name}`\n\nA variant name as a JSON string, or for a variant with fields an object `{{\"Variant\": {{fields}}}}`:\n"
380 );
381 for variant in &def.variants {
382 if variant.fields.is_empty() {
383 let _ = writeln!(out, "- `\"{}\"`", variant.name);
384 } else {
385 let fields: Vec<String> = variant
386 .fields
387 .iter()
388 .map(|field| format!("`{}`: `{}`", field.name, field.ty.display()))
389 .collect();
390 let _ = writeln!(out, "- `{}` with {}", variant.name, fields.join(", "));
391 }
392 }
393 out.push('\n');
394 }
395 }
396}
397
398pub fn markdown(app: &Application, place: &Place) -> String {
400 let cli = program(app);
401 let names = names(app);
402 let protocol = &app.protocol;
403 let (media_types, media_specs) = media_catalog();
404 let (session_types, session_specs) = session_catalog();
405 let media = !app.players.is_empty();
406 let commands: Vec<&CommandSpec> = protocol
407 .commands
408 .iter()
409 .filter(|spec| !spec.is_query())
410 .collect();
411 let queries: Vec<&CommandSpec> = protocol
412 .commands
413 .iter()
414 .filter(|spec| spec.is_query())
415 .collect();
416
417 let mut out = String::new();
418 let _ = writeln!(
419 out,
420 "---\nname: {cli}\ndescription: {}\n---\n",
421 yaml_string(&description(app, &names, &cli))
422 );
423 let _ = writeln!(out, "# {}\n", names[0]);
424 let _ = writeln!(
425 out,
426 "{} (`{}`, version {}) is built with Bern: everything it does is a command of one catalog, and its command line and MCP server reach the running app through the same catalog its own controls use. This file lists every command of this build and was generated with it; a new build brings a new copy.\n",
427 names.join(" / "),
428 app.app.bundle_id,
429 one_line(&app.app.version)
430 );
431
432 out.push_str(
433 "## Programs\n\n| Program | Relative to this file | What it is |\n| --- | --- | --- |\n",
434 );
435 let _ = writeln!(
436 out,
437 "| `{cli}` | `{}` | Command line: one command per run |",
438 place.cli
439 );
440 let _ = writeln!(
441 out,
442 "| `{cli}-mcp` | `{}` | MCP server on standard input and output |\n",
443 place.mcp
444 );
445 if let Some(installed) = &place.installed {
446 let _ = writeln!(
447 out,
448 "An installed copy of the app keeps the command line at `{installed}`, the MCP server beside it.\n"
449 );
450 }
451 let _ = writeln!(
452 out,
453 "Below, `{cli}` stands for the command line's path. Both programs talk to the app that is already running and never start it or a second copy: start the app first. `{cli} state` shows whether they reach it.\n"
454 );
455 if let Some(connect) = &place.connect {
456 let _ = writeln!(out, "{}\n", connect.trim_end());
457 }
458
459 let _ = writeln!(
460 out,
461 "## Calling commands\n\n```\n{cli} [--engine ID|SYSTEM] [--session ID] <command> [--parameter value]...\n```\n"
462 );
463 let _ = writeln!(
464 out,
465 "- Write parameters as `--name value` or `--name=value`. Text goes as it is; `int` and `float` as numbers; `bool` as `true` or `false`; an optional parameter takes `none` for no value; a choice takes its variant name. Lists, records and variants with fields go as JSON (see Types below).\n\
466 - Each run waits for the result and prints it as JSON. Exit status: 0 done, 1 the command failed (`error[code]: message` on standard error), 2 wrong usage, 3 the app is not running or the connection was lost.\n\
467 - `--engine` picks the app when several copies run: by the system it runs on (`macos`, `ios`, `tvos`, `windows`, `linux`, `android`), or by its id, which is new each time it starts; the error lists them. `--session ID` picks the window session for `ui` and `window` commands when several are open.\n\
468 - `{cli} state` prints the state below, `{cli} sessions` the open UI sessions and windows, `{cli} catalog` the whole catalog as JSON, `{cli} --help` the commands.\n"
469 );
470
471 let mcp_file = place.mcp.rsplit(['/', '\\']).next().unwrap_or(&place.mcp);
472 let _ = writeln!(
473 out,
474 "## MCP server\n\n`{cli}-mcp` speaks MCP over standard input and output. Register it with its absolute path, for example:\n\n```json\n{{ \"mcpServers\": {{ \"{cli}\": {{ \"command\": \"/absolute/path/to/{mcp_file}\" }} }} }}\n```\n"
475 );
476 let _ = writeln!(
477 out,
478 "When several copies of the app run, its tools fail with an error listing them: start it with `--engine` and the system or id, as the command line takes it, to bind it to one. Name the system in a registration, for example `\"args\": [\"--engine\", \"macos\"]`; the id is new each time the app starts.\n"
479 );
480 let _ = writeln!(
481 out,
482 "Its tools are the commands below under their **MCP tool** names, taking the parameters as a JSON object with the names written with `_`. `bern_state` reads the state and `bern_sessions` lists the UI sessions.\n"
483 );
484
485 let mut types = Types { seen: Vec::new() };
486 for field in &protocol.state {
487 types.add(&field.ty, &protocol.types);
488 }
489 for spec in &protocol.commands {
490 types.add_spec(spec, &protocol.types);
491 }
492 if media {
493 for spec in &media_specs {
494 types.add_spec(spec, &media_types);
495 }
496 }
497 for spec in &session_specs {
498 types.add_spec(spec, &session_types);
499 }
500
501 if !protocol.state.is_empty() {
502 let _ = writeln!(
503 out,
504 "## State\n\n`{cli} state` prints `{{\"revision\": n, \"values\": {{…}}}}` with these fields:\n\n| Field | Type | Meaning |\n| --- | --- | --- |"
505 );
506 for field in &protocol.state {
507 let _ = writeln!(
508 out,
509 "| `{}` | `{}` | {} |",
510 field.name,
511 field.ty.display(),
512 cell(&field.doc)
513 );
514 }
515 out.push('\n');
516 }
517
518 if !commands.is_empty() {
519 out.push_str(
520 "## Commands\n\nThese change the app's state, with or without a window open.\n\n",
521 );
522 for spec in &commands {
523 command(&mut out, &cli, spec, &protocol.types, false, &[]);
524 }
525 }
526 if !queries.is_empty() {
527 out.push_str("## Queries\n\nThese only read and change nothing.\n\n");
528 for spec in &queries {
529 command(&mut out, &cli, spec, &protocol.types, false, &[]);
530 }
531 }
532
533 if media {
534 out.push_str(
535 "## Media\n\nThe app's players, which these commands act on by `player_id`:\n\n",
536 );
537 for player in &app.players {
538 let doc = one_line(&player.doc);
539 if doc.is_empty() {
540 let _ = writeln!(out, "- `{}`", player.id);
541 } else {
542 let _ = writeln!(out, "- `{}`: {doc}", player.id);
543 }
544 }
545 out.push('\n');
546 let common = common_errors(&media_specs);
547 if !common.is_empty() {
548 let _ = writeln!(out, "Each of them may fail with {}.\n", codes(&common));
549 }
550 for spec in &media_specs {
551 command(&mut out, &cli, spec, &media_types, false, &common);
552 }
553 }
554
555 out.push_str(
556 "## Driving the UI\n\nThese act on an open window through its real controls, as a person would: a hidden or disabled control refuses, and without an open window they fail with `requires_ui_session`. Start with `ui inspect`, which lists the mounted controls with the `target` id each other command takes. A control written in a component is `component.control`; one written directly in a window takes the window's id, such as `main.add`; a control repeated for each item adds the item keys, outermost first, such as `card.open[3]`.\n\n",
557 );
558 if !app.windows.windows.is_empty() {
559 out.push_str("Window kinds, for `window open --kind`:\n\n");
560 for window in &app.windows.windows {
561 let title = window
562 .title
563 .get(&app.languages.default)
564 .map(one_line)
565 .unwrap_or_default();
566 let primary = if window.id == app.windows.primary {
567 " (the main window)"
568 } else {
569 ""
570 };
571 if title.is_empty() {
572 let _ = writeln!(out, "- `{}`{primary}", window.id);
573 } else {
574 let _ = writeln!(out, "- `{}`{primary}: {title}", window.id);
575 }
576 }
577 out.push('\n');
578 }
579 let languages: Vec<String> = app
580 .languages
581 .supported
582 .iter()
583 .map(|language| format!("`{language}`"))
584 .collect();
585 let _ = writeln!(
586 out,
587 "Interface languages, for `ui set-language`: {}.\n",
588 languages.join(", ")
589 );
590 let common = common_errors(&session_specs);
591 if !common.is_empty() {
592 let _ = writeln!(out, "Each of them may fail with {}.\n", codes(&common));
593 }
594 for spec in &session_specs {
595 command(&mut out, &cli, spec, &session_types, true, &common);
596 }
597
598 if !types.seen.is_empty() {
599 out.push_str("## Types\n\nThe JSON forms of the values the commands take and give. `int` may also be written as a string of digits; `T?` is a `T` or `null`; `[T]` is a list.\n\n");
600 for (name, table) in &types.seen {
601 type_section(&mut out, name, table);
602 }
603 }
604 while out.ends_with("\n\n") {
605 out.pop();
606 }
607 out
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use bern_ir::{AppInfo, Languages, PlayerDef, Theme, ThemeMode, WindowCatalog};
614 use bern_protocol::{
615 CommandPolicy, EnumDef, ExternalNames, FieldDef, LocalizedText, OperationKind, ParamSpec,
616 ProtocolDescriptor, RecordDef, Scope, StateField, VariantDef,
617 };
618 use std::collections::BTreeMap;
619
620 fn spec(name: &str, kind: OperationKind, params: Vec<ParamSpec>, result: Type) -> CommandSpec {
621 CommandSpec {
622 id: format!("planner.{name}"),
623 name: name.into(),
624 doc: format!("Doc of {name}.\nSecond line."),
625 kind,
626 scope: Scope::Engine,
627 params,
628 result,
629 errors: vec![],
630 policy: CommandPolicy::default(),
631 external: ExternalNames::derive("planner", name),
632 reads: vec![],
633 on: None,
634 }
635 }
636
637 fn param(name: &str, ty: Type) -> ParamSpec {
638 ParamSpec {
639 name: name.into(),
640 ty,
641 doc: format!("The {name}"),
642 }
643 }
644
645 fn app() -> Application {
646 let priority = EnumDef {
647 name: "Priority".into(),
648 variants: ["Low", "High"]
649 .iter()
650 .map(|name| VariantDef {
651 name: (*name).into(),
652 fields: vec![],
653 label: None,
654 })
655 .collect(),
656 };
657 let note = RecordDef {
658 name: "Note".into(),
659 fields: vec![FieldDef {
660 name: "title".into(),
661 ty: Type::Text,
662 doc: "Title | shown".into(),
663 content: true,
664 }],
665 };
666 let mut rename = spec(
667 "rename",
668 OperationKind::Command,
669 vec![
670 param("new_title", Type::Text),
671 param(
672 "priority",
673 Type::Enum {
674 name: "Priority".into(),
675 },
676 ),
677 ],
678 Type::Unit,
679 );
680 rename.errors = vec!["empty_title".into()];
681 rename.policy.retry = Retry::Idempotent;
682 let notes = spec(
683 "notes",
684 OperationKind::Query,
685 vec![param("limit", Type::optional(Type::Int))],
686 Type::list(Type::Record {
687 name: "Note".into(),
688 }),
689 );
690 Application {
691 ir_version: bern_ir::IR_VERSION,
692 app: AppInfo {
693 id: "my_planner".into(),
694 bundle_id: "dev.bern.planner".into(),
695 name: LocalizedText::new([("en", "Planner \"Q\""), ("zh-Hans", "计划")]),
696 version: "0.1.0".into(),
697 icon: None,
698 platform_icons: BTreeMap::new(),
699 alternate_icons: BTreeMap::new(),
700 publisher: None,
701 files_folder: false,
702 background_audio: false,
703 overlays: false,
704 },
705 languages: Languages {
706 supported: vec!["zh-Hans".into(), "en".into()],
707 default: "zh-Hans".into(),
708 strict: true,
709 },
710 theme: Theme {
711 mode: ThemeMode::System,
712 brand_seed: None,
713 },
714 protocol: ProtocolDescriptor {
715 descriptor_version: 1,
716 namespace: "planner".into(),
717 version: 1,
718 types: TypeTable {
719 enums: vec![priority],
720 records: vec![note],
721 },
722 state: vec![StateField {
723 name: "title".into(),
724 ty: Type::Text,
725 doc: "Title of the plan.".into(),
726 content: true,
727 }],
728 commands: vec![rename, notes],
729 events: vec![],
730 modules: vec![],
731 },
732 types: TypeTable::default(),
733 sessions: vec![],
734 windows: WindowCatalog {
735 handle: "Windows".into(),
736 primary: "main".into(),
737 windows: vec![],
738 accent: None,
739 palette: None,
740 appearance: None,
741 icon: None,
742 menu_bar: None,
743 status_item: None,
744 },
745 players: vec![],
746 components: vec![],
747 texts: vec![],
748 resources: vec![],
749 layouts: None,
750 contracts: vec![],
751 style: Default::default(),
752 }
753 }
754
755 #[test]
756 fn the_skill_lists_every_command_with_its_cli_and_mcp_names() {
757 let app = app();
758 let text = markdown(&app, &Place::beside_bin(&app, false));
759 let front = text
760 .strip_prefix("---\n")
761 .unwrap()
762 .split_once("\n---\n")
763 .unwrap()
764 .0;
765 assert!(
766 front.starts_with("name: my-planner\ndescription: \""),
767 "{front}"
768 );
769 assert!(
771 front.contains("Operate 计划 / Planner \\\"Q\\\" (dev.bern.planner)"),
772 "{front}"
773 );
774 assert!(
775 front.contains("Commands: planner rename, planner notes."),
776 "{front}"
777 );
778
779 assert!(text.contains("| `my-planner` | `../../bin/my-planner` |"));
780 assert!(text.contains("| `my-planner-mcp` | `../../bin/my-planner-mcp` |"));
781 assert!(text.contains("`\"args\": [\"--engine\", \"macos\"]`"));
782 assert!(text.contains("### `my-planner planner rename`\n\nDoc of rename.\nSecond line.\n"));
783 assert!(text.contains("- `--new-title <text>`: The new_title\n"));
784 assert!(text.contains("- `--priority <Low|High>`: The priority\n"));
785 assert!(text.contains(
786 "Errors: `empty_title`. Repeating it leaves the same state. MCP tool: `planner_rename`."
787 ));
788 let queries = text.find("## Queries").unwrap();
790 assert!(text.find("planner rename`").unwrap() < queries);
791 assert!(text.find("planner notes`").unwrap() > queries);
792 assert!(text.contains("- `--limit <int>` (optional; `none` for no value): The limit\n"));
793 assert!(text.contains("Result: `[Note]`."));
794 assert!(text.contains("| `title` | `text` | Title of the plan. |"));
796 assert!(text.contains("### `my-planner ui inspect`"));
797 assert!(text.contains("MCP tool: `ui_activate` (with an optional `session_id`)."));
798 assert!(
800 text.contains("Each of them may fail with `requires_ui_session`, `target_not_found`")
801 );
802 assert!(
803 text.contains(
804 "Errors: also `invalid_url`, `unsupported_file`. MCP tool: `ui_set_file`"
805 )
806 );
807 assert_eq!(text.matches("`requires_ui_session`").count(), 2, "{text}");
808 assert!(text.contains("Interface languages, for `ui set-language`: `zh-Hans`, `en`."));
809 assert!(text.contains("### `Note`\n\nA JSON object"));
810 assert!(text.contains("| `title` | `text` | Title \\| shown |"));
811 assert!(text.contains("### `Priority`\n\nOne of `\"Low\"`, `\"High\"`."));
812 assert!(text.contains("### `ControlInfo`"));
813 assert!(!text.contains("## Media"));
815 assert!(text.ends_with(".\n") && !text.ends_with("\n\n"));
816 }
817
818 #[test]
819 fn media_and_places_follow_the_target() {
820 let mut app = app();
821 app.players.push(PlayerDef {
822 id: "main".into(),
823 doc: "The video".into(),
824 });
825 let place = Place::beside_bin(&app, true)
826 .installed("%LOCALAPPDATA%\\Programs\\Planner\\bin\\my-planner.exe")
827 .connect(android_connect(&app));
828 let text = markdown(&app, &place);
829 assert!(text.contains("| `my-planner` | `..\\..\\bin\\my-planner.exe` |"));
830 assert!(text.contains("\"command\": \"/absolute/path/to/my-planner-mcp.exe\""));
831 assert!(text.contains("at `%LOCALAPPDATA%\\Programs\\Planner\\bin\\my-planner.exe`"));
832 assert!(text.contains("localabstract:bern.dev.bern.planner"));
833 assert!(text.contains("## Media\n\nThe app's players, which these commands act on by `player_id`:\n\n- `main`: The video\n"));
834 assert!(text.contains("### `my-planner media play`"));
835 }
836
837 #[test]
838 fn the_description_stays_within_what_agents_read() {
839 let mut app = app();
840 let template = app.protocol.commands[0].clone();
841 for index in 0..200 {
842 let mut spec = template.clone();
843 spec.external = ExternalNames::derive("planner", &format!("command_number_{index}"));
844 app.protocol.commands.push(spec);
845 }
846 let names = names(&app);
847 let text = description(&app, &names, "my-planner");
848 assert!(text.chars().count() <= DESCRIPTION_LIMIT, "{}", text.len());
849 assert!(
850 text.contains(" Commands: planner rename, planner notes, "),
851 "{text}"
852 );
853 assert!(text.ends_with(", …."), "{text}");
854 }
855
856 #[test]
857 fn steps_write_the_skill_only_where_the_app_listens() {
858 let app = app();
859 let root = Path::new("/out");
860 let place = Place::beside_bin(&app, false);
861 let steps = steps(&app, root, Some(&place));
862 assert!(
863 matches!(&steps[0], BuildStep::RemoveIfExists { path } if path == &root.join("skills"))
864 );
865 assert!(
866 matches!(&steps[1], BuildStep::Write { path, .. } if path == &root.join("skills/my-planner/SKILL.md"))
867 );
868 let steps = super::steps(&app, root, None);
869 assert_eq!(steps.len(), 1);
870 assert!(listens(&["ipc".to_string()]) && !listens(&[]));
871 }
872}