| 1 | //! Explicit local import bridge for Kimi-managed plugins. |
| 2 | //! |
| 3 | //! Listing is read-only and only considers immediate, canonical child |
| 4 | //! directories of `~/.kimi-code/plugins/managed`. Import requires the exact |
| 5 | //! content hash shown by listing, then routes the local directory through the |
| 6 | //! ordinary reviewed installer. The resulting Codewhale plugin still starts |
| 7 | //! disabled and untrusted; this module never launches or probes an external |
| 8 | //! Kimi application, daemon, MCP binary, or permission grant. |
| 9 | //! |
| 10 | //! FEAT-020: the managed-directory scan runs host-side in the TUI adapter; |
| 11 | //! the handler consumes the portable `PluginManagedScan` and renders it. |
| 12 | |
| 13 | use std::fmt::Write as _; |
| 14 | use std::path::Path; |
| 15 | |
| 16 | use codewhale_command_contract::facets::{CommandPluginContext, CommandPresentationContext}; |
| 17 | |
| 18 | use crate::commands::CommandResult; |
| 19 | |
| 20 | const LIST_COMMAND: &str = "/plugin import kimi [list]"; |
| 21 | const APPROVE_COMMAND: &str = "/plugin import kimi approve <name> <content-hash>"; |
| 22 | |
| 23 | pub(super) fn usage(presentation: &mut dyn CommandPresentationContext) -> String { |
| 24 | presentation |
| 25 | .translate( |
| 26 | "plugin_kimi_usage", |
| 27 | &[ |
| 28 | ("list_command", LIST_COMMAND), |
| 29 | ("approve_command", APPROVE_COMMAND), |
| 30 | ], |
| 31 | ) |
| 32 | .unwrap_or_default() |
| 33 | } |
| 34 | |
| 35 | pub(super) fn dispatch( |
| 36 | presentation: &mut dyn CommandPresentationContext, |
| 37 | plugin: &mut dyn CommandPluginContext, |
| 38 | words: &[&str], |
| 39 | home_override: Option<&Path>, |
| 40 | ) -> CommandResult { |
| 41 | match words { |
| 42 | [] | ["list"] => list(presentation, plugin, home_override), |
| 43 | ["approve", name, content_hash] => { |
| 44 | approve(presentation, plugin, name, content_hash, home_override) |
| 45 | } |
| 46 | _ => CommandResult::error(usage(presentation)), |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | fn list( |
| 51 | presentation: &mut dyn CommandPresentationContext, |
| 52 | plugin: &dyn CommandPluginContext, |
| 53 | home_override: Option<&Path>, |
| 54 | ) -> CommandResult { |
| 55 | let scan = match plugin.managed_scan(home_override) { |
| 56 | Ok(scan) => scan, |
| 57 | Err(error) => return CommandResult::error(error), |
| 58 | }; |
| 59 | let root = escape_review_path(&scan.root); |
| 60 | let mut output = presentation |
| 61 | .translate("plugin_kimi_managed_root_heading", &[("root", &root)]) |
| 62 | .unwrap_or_default(); |
| 63 | output.push('\n'); |
| 64 | if scan.candidates.is_empty() { |
| 65 | output.push_str(" "); |
| 66 | output.push_str( |
| 67 | &presentation |
| 68 | .translate("plugin_kimi_none_found", &[]) |
| 69 | .unwrap_or_default(), |
| 70 | ); |
| 71 | output.push('\n'); |
| 72 | } |
| 73 | for candidate in &scan.candidates { |
| 74 | let name = escape_review_text(&candidate.name); |
| 75 | let version = escape_review_text(&candidate.version); |
| 76 | let license = candidate |
| 77 | .license |
| 78 | .as_deref() |
| 79 | .map(escape_review_text) |
| 80 | .unwrap_or_else(|| { |
| 81 | presentation |
| 82 | .translate("plugin_kimi_license_unspecified", &[]) |
| 83 | .unwrap_or_default() |
| 84 | }); |
| 85 | let applicability = presentation |
| 86 | .translate( |
| 87 | if candidate.applicable { |
| 88 | "plugin_kimi_applicable" |
| 89 | } else { |
| 90 | "plugin_kimi_not_applicable" |
| 91 | }, |
| 92 | &[], |
| 93 | ) |
| 94 | .unwrap_or_default(); |
| 95 | let inventory = escape_review_text(&candidate.inventory); |
| 96 | let summary = presentation |
| 97 | .translate( |
| 98 | "plugin_kimi_candidate_summary", |
| 99 | &[ |
| 100 | ("name", &name), |
| 101 | ("version", &version), |
| 102 | ("license", &license), |
| 103 | ("applicability", &applicability), |
| 104 | ("inventory", &inventory), |
| 105 | ], |
| 106 | ) |
| 107 | .unwrap_or_default(); |
| 108 | let _ = writeln!(output, "\n{summary}"); |
| 109 | |
| 110 | let path = escape_review_path(&candidate.canonical_path); |
| 111 | let approve_command = format!( |
| 112 | "/plugin import kimi approve {} {}", |
| 113 | candidate.name, candidate.content_hash |
| 114 | ); |
| 115 | let details = presentation |
| 116 | .translate( |
| 117 | "plugin_kimi_candidate_details", |
| 118 | &[ |
| 119 | ("path", &path), |
| 120 | ("content_hash", &candidate.content_hash), |
| 121 | ("capability_hash", &candidate.capability_hash), |
| 122 | ("approve_command", &approve_command), |
| 123 | ], |
| 124 | ) |
| 125 | .unwrap_or_default(); |
| 126 | let _ = writeln!(output, "{details}"); |
| 127 | } |
| 128 | if !scan.rejected.is_empty() { |
| 129 | output.push('\n'); |
| 130 | output.push_str( |
| 131 | &presentation |
| 132 | .translate("plugin_kimi_rejected_heading", &[]) |
| 133 | .unwrap_or_default(), |
| 134 | ); |
| 135 | output.push('\n'); |
| 136 | for rejection in &scan.rejected { |
| 137 | let _ = writeln!(output, " - {rejection}"); |
| 138 | } |
| 139 | } |
| 140 | output.push('\n'); |
| 141 | output.push_str( |
| 142 | &presentation |
| 143 | .translate("plugin_kimi_inspection_footer", &[]) |
| 144 | .unwrap_or_default(), |
| 145 | ); |
| 146 | CommandResult::message(output) |
| 147 | } |
| 148 | |
| 149 | fn approve( |
| 150 | presentation: &mut dyn CommandPresentationContext, |
| 151 | plugin: &mut dyn CommandPluginContext, |
| 152 | name: &str, |
| 153 | expected_hash: &str, |
| 154 | home_override: Option<&Path>, |
| 155 | ) -> CommandResult { |
| 156 | let scan = match plugin.managed_scan(home_override) { |
| 157 | Ok(scan) => scan, |
| 158 | Err(error) => return CommandResult::error(error), |
| 159 | }; |
| 160 | let Some(candidate) = scan |
| 161 | .candidates |
| 162 | .into_iter() |
| 163 | .find(|candidate| candidate.name == name) |
| 164 | else { |
| 165 | let name = escape_review_text(name); |
| 166 | return CommandResult::error( |
| 167 | presentation |
| 168 | .translate( |
| 169 | "plugin_kimi_candidate_missing", |
| 170 | &[("name", &name), ("list_command", "/plugin import kimi")], |
| 171 | ) |
| 172 | .unwrap_or_default(), |
| 173 | ); |
| 174 | }; |
| 175 | if candidate.content_hash != expected_hash { |
| 176 | let name = escape_review_text(name); |
| 177 | let expected = escape_review_text(expected_hash); |
| 178 | return CommandResult::error( |
| 179 | presentation |
| 180 | .translate( |
| 181 | "plugin_kimi_candidate_changed", |
| 182 | &[ |
| 183 | ("name", &name), |
| 184 | ("expected", &expected), |
| 185 | ("actual", &candidate.content_hash), |
| 186 | ("list_command", "/plugin import kimi"), |
| 187 | ], |
| 188 | ) |
| 189 | .unwrap_or_default(), |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | // The facet revalidates and copies the source through the ordinary local |
| 194 | // installer; its result is always rediscovered disabled/untrusted and |
| 195 | // presents the post-copy authority review before any activation. |
| 196 | super::install_bundle_with_expected_hash( |
| 197 | presentation, |
| 198 | plugin, |
| 199 | &candidate.canonical_path, |
| 200 | expected_hash, |
| 201 | ) |
| 202 | } |
| 203 | |
| 204 | pub(super) fn escape_review_path(path: &Path) -> String { |
| 205 | super::escape_review_path(path) |
| 206 | } |
| 207 | |
| 208 | pub(super) fn escape_review_text(value: &str) -> String { |
| 209 | super::escape_review_text(value) |
| 210 | } |
| 211 |