opencode_ctl/
skill_asset.rs1use crate::error::{ErrorCode, ToolError};
8use crate::host::Host;
9
10pub const DRIVER_SKILL: &str = include_str!("../skill/opencode-ctl-driver.md");
12
13pub const DOCTOR_SKILL: &str = include_str!("../skill/opencode-ctl-doctor.md");
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SkillName {
18 Driver,
19 Doctor,
20}
21
22impl SkillName {
23 pub fn as_str(self) -> &'static str {
24 match self {
25 SkillName::Driver => "opencode-ctl-driver",
26 SkillName::Doctor => "opencode-ctl-doctor",
27 }
28 }
29
30 pub fn parse(s: &str) -> Option<SkillName> {
31 match s {
32 "opencode-ctl-driver" => Some(SkillName::Driver),
33 "opencode-ctl-doctor" => Some(SkillName::Doctor),
34 _ => None,
35 }
36 }
37
38 fn template(self) -> &'static str {
39 match self {
40 SkillName::Driver => DRIVER_SKILL,
41 SkillName::Doctor => DOCTOR_SKILL,
42 }
43 }
44}
45
46fn strip_header(t: &str) -> &str {
49 if !t.starts_with("<!--") {
50 return t;
51 }
52 match t.find("-->") {
53 Some(i) => match t[i..].find('\n') {
54 Some(j) => &t[i + j + 1..],
55 None => "",
56 },
57 None => t,
58 }
59}
60
61fn host_name(host: Host) -> &'static str {
62 match host {
63 Host::Claude | Host::Unknown => "Claude Code",
64 Host::Qwen => "Qwen Code",
65 Host::Agy => "Antigravity",
66 Host::Opencode => "OpenCode",
67 }
68}
69
70fn binary(host: Host) -> &'static str {
71 match host {
72 Host::Claude | Host::Unknown => "${CLAUDE_PLUGIN_ROOT}/bin/opencode-ctl-cc-mcp",
73 Host::Qwen => "~/.qwen/extensions/opencode-ctl-mcp-qwen/bin/opencode-ctl-qwen-mcp",
74 Host::Agy => "opencode-ctl-agy-mcp",
75 Host::Opencode => {
76 "the first element of mcp.servers.opencode-ctl.command in your opencode.jsonc (an absolute path to opencode-ctl-cc-mcp)"
77 }
78 }
79}
80
81fn argument_hint(host: Host) -> &'static str {
82 match host {
83 Host::Claude | Host::Agy | Host::Unknown => "[install] [verbose]",
84 Host::Opencode => "[verbose]",
85 Host::Qwen => "",
86 }
87}
88
89fn install_clause(host: Host) -> &'static str {
90 match host {
91 Host::Claude | Host::Agy | Host::Unknown => {
92 ", or when the user wants opencode-pair installed (argument install)"
93 }
94 Host::Opencode | Host::Qwen => "",
95 }
96}
97
98fn install_section(host: Host) -> &'static str {
100 match host {
101 Host::Claude | Host::Agy | Host::Unknown => {
102 "# Installing opencode-pair\n\nWhen invoked with `install`, run `{{BINARY}} install-launcher` and print its PATH hint."
103 }
104 Host::Qwen => {
105 "# Installing opencode-pair\n\nWhen invoked with no argument and `{{BINARY}} doctor-json` reports `launcher_link: missing`, run `{{BINARY}} install-launcher`."
106 }
107 Host::Opencode => "",
108 }
109}
110
111pub fn render(name: SkillName, host: Host) -> Result<String, ToolError> {
114 if host == Host::Unknown {
115 return Err(ToolError::new(
116 ErrorCode::InvalidArgument,
117 "cannot render a skill for an unknown host",
118 ));
119 }
120 let doctor = host.slash_form(SkillName::Doctor.as_str());
121 let hint = argument_hint(host);
122 let host_flag = if host == Host::Opencode {
123 " --host opencode"
124 } else {
125 ""
126 };
127 let mut out =
129 strip_header(name.template()).replace("{{INSTALL_SECTION}}", install_section(host));
130 for (token, value) in [
131 ("{{INVOCATION}}", host.slash_form(name.as_str())),
132 ("{{DOCTOR_INVOCATION}}", doctor),
133 ("{{BINARY}}", binary(host).to_string()),
134 ("{{ARGUMENT_HINT}}", hint.to_string()),
135 ("{{ALLOWED_TOOLS}}", "Bash, Read, Write".to_string()),
136 ("{{INSTALL_CLAUSE}}", install_clause(host).to_string()),
137 ("{{HOST_FLAG}}", host_flag.to_string()),
138 ("{{HOST_NAME}}", host_name(host).to_string()),
139 ] {
140 out = out.replace(token, &value);
141 }
142 if out.contains("{{") {
143 return Err(ToolError::new(
144 ErrorCode::CliError,
145 format!(
146 "skill template {} has an unsubstituted token",
147 name.as_str()
148 ),
149 ));
150 }
151 let mut out = out.trim_end().to_string();
153 out.push('\n');
154 Ok(out)
155}