• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dcdpr / jp / 24178395726

09 Apr 2026 07:36AM UTC coverage: 62.548% (-1.9%) from 64.459%
24178395726

Pull #521

github

web-flow
Merge 5d43b1c77 into 7a6c59790
Pull Request #521: feat(plugin): Add command plugin system (RFD 072)

137 of 1183 new or added lines in 16 files covered. (11.58%)

6 existing lines in 3 files now uncovered.

20395 of 32607 relevant lines covered (62.55%)

298.08 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.45
/crates/jp_cli/src/cmd/plugin/dispatch.rs
1
//! Host-side plugin message loop.
2
//!
3
//! Spawns the plugin binary, sends `init`, and relays workspace queries until
4
//! the plugin sends `exit` or the process terminates.
5

6
use std::{
7
    collections::HashSet,
8
    io::{BufRead, BufReader, Write},
9
    process::{Command, Stdio},
10
    sync::{
11
        Arc, Mutex,
12
        atomic::{AtomicBool, Ordering},
13
    },
14
    thread,
15
};
16

17
use camino::{Utf8Path, Utf8PathBuf};
18
use jp_config::{
19
    AppConfig,
20
    plugins::{
21
        PluginsConfig,
22
        command::{CommandPluginConfig, RunPolicy},
23
    },
24
};
25
use jp_inquire::{InlineOption, InlineSelect};
26
use jp_plugin::{
27
    PROTOCOL_VERSION,
28
    message::{
29
        ConfigResponse, ConversationSummary, ConversationsResponse, DescribeResponse,
30
        ErrorResponse, EventsResponse, HostToPlugin, InitMessage, LogMessage, PathsInfo,
31
        PluginToHost, WorkspaceInfo,
32
    },
33
};
34
use jp_workspace::Workspace;
35
use serde_json::Value;
36
use tracing::{debug, error, trace, warn};
37

38
use super::registry;
39
use crate::{Ctx, cmd, signals::SignalPair};
40

41
/// Run a plugin binary, handling the full protocol lifecycle.
42
///
43
/// `binary` is the path to the plugin executable. `args` are the remaining CLI
44
/// arguments to forward.
NEW
45
pub(crate) fn run_plugin(
×
NEW
46
    binary: &Utf8Path,
×
NEW
47
    args: &[String],
×
NEW
48
    workspace: &Workspace,
×
NEW
49
    config: &Arc<AppConfig>,
×
NEW
50
    signals: &SignalPair,
×
NEW
51
    log_level: u8,
×
NEW
52
) -> Result<(), cmd::Error> {
×
NEW
53
    let config_json = serde_json::to_value(config.as_ref().to_partial())
×
NEW
54
        .map_err(|e| cmd::Error::from(format!("failed to serialize config: {e}")))?;
×
55

NEW
56
    let storage_path = workspace
×
NEW
57
        .storage_path()
×
NEW
58
        .ok_or_else(|| cmd::Error::from("workspace has no storage configured"))?;
×
59

NEW
60
    let home = std::env::home_dir().and_then(|p| camino::Utf8PathBuf::from_path_buf(p).ok());
×
61

NEW
62
    let init = HostToPlugin::Init(InitMessage {
×
NEW
63
        version: PROTOCOL_VERSION,
×
NEW
64
        workspace: WorkspaceInfo {
×
NEW
65
            root: workspace.root().to_owned(),
×
NEW
66
            storage: storage_path.to_owned(),
×
NEW
67
            id: workspace.id().to_string(),
×
NEW
68
        },
×
NEW
69
        paths: PathsInfo {
×
NEW
70
            user_data: jp_workspace::user_data_dir().ok(),
×
NEW
71
            user_config: jp_config::fs::user_global_config_path(home.as_deref()),
×
NEW
72
            user_workspace: workspace.user_storage_path().map(ToOwned::to_owned),
×
NEW
73
        },
×
NEW
74
        config: config_json.clone(),
×
NEW
75
        args: args.to_vec(),
×
NEW
76
        log_level,
×
NEW
77
    });
×
78

NEW
79
    debug!(%binary, "Spawning plugin.");
×
80

NEW
81
    let mut cmd = Command::new(binary);
×
NEW
82
    cmd.stdin(Stdio::piped())
×
NEW
83
        .stdout(Stdio::piped())
×
NEW
84
        .stderr(Stdio::piped());
×
85

86
    // Prevent the child from receiving SIGINT/SIGTERM directly. The host
87
    // sends `Shutdown` over the protocol instead, giving the plugin a
88
    // chance to exit gracefully.
89
    #[cfg(unix)]
90
    {
91
        use std::os::unix::process::CommandExt as _;
NEW
92
        cmd.process_group(0);
×
93
    }
94

NEW
95
    let mut child = cmd
×
NEW
96
        .spawn()
×
NEW
97
        .map_err(|e| cmd::Error::from(format!("failed to spawn plugin: {e}")))?;
×
98

NEW
99
    let child_stdin = child.stdin.take().expect("stdin piped");
×
NEW
100
    let stdout = child.stdout.take().expect("stdout piped");
×
NEW
101
    let stderr = child.stderr.take().expect("stderr piped");
×
102

103
    // Wrap stdin so the shutdown thread can write to it too.
NEW
104
    let stdin = Arc::new(Mutex::new(child_stdin));
×
105

106
    // Forward stderr to tracing in a background thread.
NEW
107
    let stderr_handle = thread::spawn(move || {
×
NEW
108
        let reader = BufReader::new(stderr);
×
NEW
109
        for line in reader.lines() {
×
NEW
110
            match line {
×
NEW
111
                Ok(line) => trace!(target: "plugin::stderr", "{}", line),
×
NEW
112
                Err(e) => {
×
NEW
113
                    warn!("Error reading plugin stderr: {e}");
×
NEW
114
                    break;
×
115
                }
116
            }
117
        }
NEW
118
    });
×
119

120
    // Shutdown thread: sends `Shutdown` directly to the plugin's stdin
121
    // when a signal arrives. If the plugin doesn't exit within the grace
122
    // period, sends SIGKILL.
NEW
123
    let shutdown_sent = Arc::new(AtomicBool::new(false));
×
NEW
124
    let shutdown_writer = stdin.clone();
×
NEW
125
    let shutdown_flag = shutdown_sent.clone();
×
NEW
126
    let child_id = child.id();
×
NEW
127
    let mut shutdown_rx = signals.receiver.resubscribe();
×
NEW
128
    let shutdown_handle = thread::spawn(move || {
×
NEW
129
        if futures::executor::block_on(shutdown_rx.recv()).is_err() {
×
NEW
130
            return;
×
NEW
131
        }
×
132

133
        // Send Shutdown over the protocol.
NEW
134
        if let Ok(mut writer) = shutdown_writer.lock() {
×
NEW
135
            drop(write_message(&mut *writer, &HostToPlugin::Shutdown));
×
NEW
136
        }
×
NEW
137
        shutdown_flag.store(true, Ordering::Release);
×
138

139
        // Grace period: wait in short intervals so we don't block cleanup
140
        // if the plugin exits promptly.
NEW
141
        for _ in 0..50 {
×
NEW
142
            thread::sleep(std::time::Duration::from_millis(100));
×
NEW
143
            if !is_process_alive(child_id) {
×
NEW
144
                return;
×
NEW
145
            }
×
146
        }
147

NEW
148
        kill_child(child_id);
×
NEW
149
    });
×
150

151
    // Send init.
152
    {
NEW
153
        let mut writer = stdin.lock().expect("stdin lock poisoned");
×
NEW
154
        write_message(&mut *writer, &init)
×
NEW
155
            .map_err(|e| cmd::Error::from(format!("failed to send init: {e}")))?;
×
156
    }
157

158
    // Read messages from plugin.
NEW
159
    let reader = BufReader::new(stdout);
×
NEW
160
    let result = message_loop(reader, &stdin, workspace, &config_json, &shutdown_sent);
×
161

162
    // Always clean up, even on error.
NEW
163
    drop(child.wait());
×
NEW
164
    drop(stderr_handle.join());
×
NEW
165
    drop(shutdown_handle);
×
166

NEW
167
    result
×
NEW
168
}
×
169

170
/// The main message loop: reads plugin requests and sends responses.
171
fn message_loop(
1✔
172
    reader: BufReader<impl std::io::Read>,
1✔
173
    stdin: &Mutex<impl Write>,
1✔
174
    workspace: &Workspace,
1✔
175
    config_json: &Value,
1✔
176
    shutdown_sent: &AtomicBool,
1✔
177
) -> Result<(), cmd::Error> {
1✔
178
    for line in reader.lines() {
2✔
179
        let line =
2✔
180
            line.map_err(|e| cmd::Error::from(format!("failed to read from plugin: {e}")))?;
2✔
181

182
        if line.trim().is_empty() {
2✔
NEW
183
            continue;
×
184
        }
2✔
185

186
        let msg: PluginToHost = serde_json::from_str(&line)
2✔
187
            .map_err(|e| cmd::Error::from(format!("invalid plugin message: {e}: {line}")))?;
2✔
188

189
        trace!(?msg, "Received plugin message.");
2✔
190

191
        let mut writer = stdin.lock().expect("stdin lock poisoned");
2✔
192

193
        match msg {
2✔
194
            PluginToHost::Ready => {
195
                debug!("Plugin signaled ready.");
1✔
196
            }
197

NEW
198
            PluginToHost::ListConversations(req) => {
×
NEW
199
                let response = handle_list_conversations(workspace, req.id);
×
NEW
200
                write_message(&mut *writer, &response)?;
×
201
            }
202

NEW
203
            PluginToHost::ReadEvents(req) => {
×
NEW
204
                let response = handle_read_events(workspace, &req.conversation, req.id);
×
NEW
205
                write_message(&mut *writer, &response)?;
×
206
            }
207

NEW
208
            PluginToHost::ReadConfig(req) => {
×
NEW
209
                let response = handle_read_config(config_json, req.path, req.id);
×
NEW
210
                write_message(&mut *writer, &response)?;
×
211
            }
212

NEW
213
            PluginToHost::Print(print) => {
×
NEW
214
                // In Phase 1, write to stdout directly. Full printer
×
NEW
215
                // integration comes later when we thread through &Printer.
×
NEW
216
                let stdout = std::io::stdout();
×
NEW
217
                let mut handle = stdout.lock();
×
NEW
218
                drop(handle.write_all(print.text.as_bytes()));
×
NEW
219
                drop(handle.flush());
×
NEW
220
            }
×
221

NEW
222
            PluginToHost::Log(log) => {
×
NEW
223
                emit_log(&log);
×
NEW
224
            }
×
225

226
            PluginToHost::Describe(_) => {
NEW
227
                debug!("Ignoring describe in message loop.");
×
228
            }
229

230
            PluginToHost::Exit(exit) => {
1✔
231
                debug!(code = exit.code, "Plugin exited.");
1✔
232
                if exit.code == 0 {
1✔
233
                    return Ok(());
1✔
NEW
234
                }
×
NEW
235
                return match exit.reason {
×
NEW
236
                    Some(reason) => Err(cmd::Error::from((exit.code, reason))),
×
NEW
237
                    None => Err(cmd::Error::from(exit.code)),
×
238
                };
239
            }
240
        }
241
    }
242

243
    // Plugin's stdout closed without an `exit` message. If we sent a
244
    // shutdown, this is expected (the child exited after receiving it).
NEW
245
    if shutdown_sent.load(Ordering::Acquire) {
×
NEW
246
        debug!("Plugin exited after shutdown.");
×
NEW
247
        return Ok(());
×
NEW
248
    }
×
249

NEW
250
    error!("Plugin exited without sending exit message.");
×
NEW
251
    Err(cmd::Error::from((
×
NEW
252
        1u8,
×
NEW
253
        "plugin exited unexpectedly without sending exit message",
×
NEW
254
    )))
×
255
}
1✔
256

NEW
257
fn handle_list_conversations(workspace: &Workspace, req_id: Option<String>) -> HostToPlugin {
×
NEW
258
    let data: Vec<ConversationSummary> = workspace
×
NEW
259
        .conversations()
×
NEW
260
        .map(|(id, meta)| ConversationSummary {
×
NEW
261
            id: id.as_deciseconds().to_string(),
×
NEW
262
            title: meta.title.clone(),
×
NEW
263
            last_activated_at: meta.last_activated_at,
×
NEW
264
            events_count: meta.events_count,
×
NEW
265
        })
×
NEW
266
        .collect();
×
267

NEW
268
    HostToPlugin::Conversations(ConversationsResponse { id: req_id, data })
×
NEW
269
}
×
270

NEW
271
fn handle_read_events(
×
NEW
272
    workspace: &Workspace,
×
NEW
273
    conversation_id: &str,
×
NEW
274
    req_id: Option<String>,
×
NEW
275
) -> HostToPlugin {
×
NEW
276
    let conv_id = match jp_conversation::ConversationId::try_from_deciseconds_str(conversation_id) {
×
NEW
277
        Ok(id) => id,
×
NEW
278
        Err(e) => {
×
NEW
279
            return HostToPlugin::Error(ErrorResponse {
×
NEW
280
                id: req_id,
×
NEW
281
                request: Some("read_events".to_owned()),
×
NEW
282
                message: format!("invalid conversation ID: {e}"),
×
NEW
283
            });
×
284
        }
285
    };
286

NEW
287
    let handle = match workspace.acquire_conversation(&conv_id) {
×
NEW
288
        Ok(h) => h,
×
NEW
289
        Err(e) => {
×
NEW
290
            return HostToPlugin::Error(ErrorResponse {
×
NEW
291
                id: req_id,
×
NEW
292
                request: Some("read_events".to_owned()),
×
NEW
293
                message: format!("conversation not found: {e}"),
×
NEW
294
            });
×
295
        }
296
    };
297

NEW
298
    let events = match workspace.events(&handle) {
×
NEW
299
        Ok(stream) => stream,
×
NEW
300
        Err(e) => {
×
NEW
301
            return HostToPlugin::Error(ErrorResponse {
×
NEW
302
                id: req_id,
×
NEW
303
                request: Some("read_events".to_owned()),
×
NEW
304
                message: format!("failed to load events: {e}"),
×
NEW
305
            });
×
306
        }
307
    };
308

309
    // Serialize events to JSON values, then decode base64-encoded storage
310
    // fields so plugins receive plain text.
NEW
311
    let (_, mut event_values) = match events.to_parts() {
×
NEW
312
        Ok(parts) => parts,
×
NEW
313
        Err(e) => {
×
NEW
314
            return HostToPlugin::Error(ErrorResponse {
×
NEW
315
                id: req_id,
×
NEW
316
                request: Some("read_events".to_owned()),
×
NEW
317
                message: format!("failed to serialize events: {e}"),
×
NEW
318
            });
×
319
        }
320
    };
321

NEW
322
    for value in &mut event_values {
×
NEW
323
        jp_conversation::decode_event_value(value);
×
NEW
324
    }
×
325

NEW
326
    HostToPlugin::Events(EventsResponse {
×
NEW
327
        id: req_id,
×
NEW
328
        conversation: conversation_id.to_owned(),
×
NEW
329
        data: event_values,
×
NEW
330
    })
×
NEW
331
}
×
332

333
fn handle_read_config(
3✔
334
    config_json: &Value,
3✔
335
    path: Option<String>,
3✔
336
    req_id: Option<String>,
3✔
337
) -> HostToPlugin {
3✔
338
    let data = match &path {
3✔
339
        Some(path) => {
2✔
340
            let mut current = config_json;
2✔
341
            for segment in path.split('.') {
3✔
342
                match current.get(segment) {
3✔
343
                    Some(v) => current = v,
2✔
344
                    None => {
345
                        return HostToPlugin::Error(ErrorResponse {
1✔
346
                            id: req_id,
1✔
347
                            request: Some("read_config".to_owned()),
1✔
348
                            message: format!("config path not found: {path}"),
1✔
349
                        });
1✔
350
                    }
351
                }
352
            }
353
            current.clone()
1✔
354
        }
355
        None => config_json.clone(),
1✔
356
    };
357

358
    HostToPlugin::Config(ConfigResponse {
2✔
359
        id: req_id,
2✔
360
        path,
2✔
361
        data,
2✔
362
    })
2✔
363
}
3✔
364

NEW
365
fn emit_log(log: &LogMessage) {
×
NEW
366
    match log.level.as_str() {
×
NEW
367
        "trace" => trace!(target: "plugin", message = %log.message),
×
NEW
368
        "debug" => debug!(target: "plugin", message = %log.message),
×
NEW
369
        "info" => tracing::info!(target: "plugin", message = %log.message),
×
NEW
370
        "warn" => warn!(target: "plugin", message = %log.message),
×
NEW
371
        "error" => error!(target: "plugin", message = %log.message),
×
372
        _ => {
NEW
373
            warn!(target: "plugin", level = %log.level, message = %log.message, "unknown log level");
×
374
        }
375
    }
NEW
376
}
×
377

NEW
378
fn write_message(writer: &mut impl Write, msg: &HostToPlugin) -> Result<(), cmd::Error> {
×
NEW
379
    let json = serde_json::to_string(msg)
×
NEW
380
        .map_err(|e| cmd::Error::from(format!("failed to serialize message: {e}")))?;
×
NEW
381
    writeln!(writer, "{json}")
×
NEW
382
        .map_err(|e| cmd::Error::from(format!("failed to write to plugin stdin: {e}")))?;
×
NEW
383
    writer
×
NEW
384
        .flush()
×
NEW
385
        .map_err(|e| cmd::Error::from(format!("failed to flush plugin stdin: {e}")))?;
×
NEW
386
    Ok(())
×
NEW
387
}
×
388

389
/// Check if a process is still alive by PID.
390
#[cfg(unix)]
NEW
391
fn is_process_alive(pid: u32) -> bool {
×
392
    // kill with signal 0 checks existence without sending a signal.
NEW
393
    unsafe { libc::kill(libc::pid_t::from(pid.cast_signed()), 0) == 0 }
×
NEW
394
}
×
395

396
#[cfg(windows)]
397
fn is_process_alive(pid: u32) -> bool {
398
    use windows_sys::Win32::{
399
        Foundation::{CloseHandle, STILL_ACTIVE},
400
        System::Threading::{GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
401
    };
402

403
    unsafe {
404
        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
405
        if handle.is_null() {
406
            return false;
407
        }
408
        let mut exit_code: u32 = 0;
409
        let ok = GetExitCodeProcess(handle, &mut exit_code);
410
        CloseHandle(handle);
411
        ok != 0 && (exit_code as i32) == STILL_ACTIVE
412
    }
413
}
414

415
/// Send SIGKILL to a child process by PID.
416
///
417
/// Used as a last resort when the plugin doesn't exit within the grace
418
/// period after receiving `Shutdown`.
419
#[cfg(unix)]
NEW
420
fn kill_child(pid: u32) {
×
421
    // SAFETY: We're sending a signal to a process we spawned.
NEW
422
    unsafe {
×
NEW
423
        libc::kill(libc::pid_t::from(pid.cast_signed()), libc::SIGKILL);
×
NEW
424
    }
×
NEW
425
    debug!(pid, "Sent SIGKILL to plugin after grace period.");
×
NEW
426
}
×
427

428
#[cfg(windows)]
429
fn kill_child(pid: u32) {
430
    use windows_sys::Win32::{
431
        Foundation::CloseHandle,
432
        System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess},
433
    };
434

435
    // SAFETY: We're terminating a process we spawned.
436
    unsafe {
437
        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
438
        if !handle.is_null() {
439
            TerminateProcess(handle, 1);
440
            CloseHandle(handle);
441
        }
442
    }
443
    debug!(pid, "Sent TerminateProcess to plugin after grace period.");
444
}
445

446
/// Search `$PATH` for a plugin binary matching the given subcommand segments.
447
///
448
/// For `["serve"]`, looks for `jp-serve`. For `["conversation", "export"]`,
449
/// looks for `jp-conversation-export`.
450
pub(crate) fn find_plugin_binary(segments: &[&str]) -> Option<Utf8PathBuf> {
1✔
451
    let name = format!("jp-{}", segments.join("-"));
1✔
452
    which::which(&name)
1✔
453
        .ok()
1✔
454
        .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1✔
455
}
1✔
456

457
/// Find any existing plugin binary without downloading or prompting.
458
///
459
/// Checks the install directory first, then `$PATH`. Used for non-mutating
460
/// operations like help requests.
NEW
461
pub(crate) fn find_any_plugin_binary(name: &str) -> Option<Utf8PathBuf> {
×
NEW
462
    if let Some(path) = registry::find_installed(name) {
×
NEW
463
        return Some(path);
×
NEW
464
    }
×
NEW
465
    let segments: Vec<&str> = name.split('-').collect();
×
NEW
466
    find_plugin_binary(&segments)
×
NEW
467
}
×
468

469
/// Resolve a plugin binary through multiple sources:
470
///
471
/// 1. User-local install directory (previously installed plugins)
472
/// 2. Plugin registry (auto-install if official, prompt if third-party)
473
/// 3. `$PATH` (with approval check for unapproved plugins)
474
///
475
/// The `plugins_config` drives installation and execution policy. Per-plugin
476
/// settings override the defaults from the registry (official vs third-party).
NEW
477
pub(crate) async fn resolve_plugin_binary(
×
NEW
478
    name: &str,
×
NEW
479
    plugins_config: &PluginsConfig,
×
NEW
480
    is_tty: bool,
×
NEW
481
) -> Result<Utf8PathBuf, cmd::Error> {
×
NEW
482
    let plugin_cfg = plugins_config.command.get(name);
×
483

484
    // Explicit deny in config.
NEW
485
    if plugin_cfg.is_some_and(|c| c.run == Some(RunPolicy::Deny)) {
×
NEW
486
        return Err(cmd::Error::from(format!(
×
NEW
487
            "plugin `{name}` is denied by configuration (plugins.command.{name}.run = \"deny\")"
×
NEW
488
        )));
×
NEW
489
    }
×
490

491
    // 1. Already installed locally.
NEW
492
    if let Some(path) = registry::find_installed(name) {
×
NEW
493
        debug!(name, %path, "Found installed plugin.");
×
NEW
494
        verify_checksum(name, &path, plugin_cfg)?;
×
NEW
495
        return Ok(path);
×
NEW
496
    }
×
497

498
    // 2. Check registry.
NEW
499
    if let Some(path) = try_registry_install(name, plugins_config, is_tty).await? {
×
NEW
500
        return Ok(path);
×
NEW
501
    }
×
502

503
    // 3. Check $PATH with run policy.
NEW
504
    let segments: Vec<&str> = name.split('-').collect();
×
NEW
505
    if let Some(path) = find_plugin_binary(&segments) {
×
NEW
506
        check_run_policy(name, &path, plugin_cfg, is_tty)?;
×
NEW
507
        return Ok(path);
×
NEW
508
    }
×
509

NEW
510
    Err(cmd::Error::from(format!(
×
NEW
511
        "unknown command `{name}`. No built-in command, registry plugin, or `jp-{name}` binary \
×
NEW
512
         found on $PATH."
×
NEW
513
    )))
×
NEW
514
}
×
515

516
/// Verify a binary's checksum against the config-pinned value, if any.
NEW
517
fn verify_checksum(
×
NEW
518
    name: &str,
×
NEW
519
    binary_path: &Utf8Path,
×
NEW
520
    plugin_cfg: Option<&CommandPluginConfig>,
×
NEW
521
) -> Result<(), cmd::Error> {
×
NEW
522
    let Some(checksum) = plugin_cfg.and_then(|c| c.checksum.as_ref()) else {
×
NEW
523
        return Ok(());
×
524
    };
525

NEW
526
    let actual = registry::sha256_file(binary_path)?;
×
NEW
527
    if actual != checksum.value {
×
NEW
528
        return Err(cmd::Error::from(format!(
×
NEW
529
            "plugin `{name}` binary checksum mismatch.\nexpected: {}\nactual:   {actual}\nThe \
×
NEW
530
             binary at {binary_path} has changed since it was pinned. Update \
×
NEW
531
             plugins.command.{name}.checksum.value in your config to accept the new binary.",
×
NEW
532
            checksum.value,
×
NEW
533
        )));
×
NEW
534
    }
×
535

NEW
536
    Ok(())
×
NEW
537
}
×
538

539
/// Try to install a plugin from the cached registry.
NEW
540
async fn try_registry_install(
×
NEW
541
    name: &str,
×
NEW
542
    plugins_config: &PluginsConfig,
×
NEW
543
    is_tty: bool,
×
NEW
544
) -> Result<Option<Utf8PathBuf>, cmd::Error> {
×
NEW
545
    let Some(reg) = registry::load_cached() else {
×
NEW
546
        return Ok(None);
×
547
    };
548

549
    // Find the registry entry whose `id` matches the requested name.
550
    // In Phase 5, this will use the command path (registry key) for
551
    // multi-segment routing. For now, we match on `id`.
NEW
552
    let Some(plugin) = reg.plugins.values().find(|p| p.id == name) else {
×
NEW
553
        return Ok(None);
×
554
    };
555

556
    // Only handle command plugins.
NEW
557
    let jp_plugin::registry::PluginKind::Command { ref binaries, .. } = plugin.kind else {
×
NEW
558
        return Ok(None);
×
559
    };
560

NEW
561
    let target = registry::current_target();
×
NEW
562
    let Some(binary_info) = binaries.get(&target) else {
×
NEW
563
        return Ok(None);
×
564
    };
565

NEW
566
    let id = &plugin.id;
×
NEW
567
    let plugin_cfg = plugins_config.command.get(id);
×
568

569
    // Check if auto-install is allowed.
NEW
570
    let auto_install = plugin_cfg
×
NEW
571
        .and_then(|c| c.install)
×
NEW
572
        .unwrap_or(plugins_config.auto_install);
×
573

NEW
574
    if !auto_install && !plugin.official {
×
NEW
575
        return Ok(None);
×
NEW
576
    }
×
577

578
    // Determine run policy: config > registry default.
NEW
579
    let run_policy = plugin_cfg
×
NEW
580
        .and_then(|c| c.run)
×
NEW
581
        .unwrap_or(if plugin.official {
×
NEW
582
            RunPolicy::Unattended
×
583
        } else {
NEW
584
            RunPolicy::Ask
×
585
        });
586

NEW
587
    match run_policy {
×
588
        RunPolicy::Deny => {
NEW
589
            return Err(cmd::Error::from(format!(
×
NEW
590
                "plugin `{id}` is denied by configuration"
×
NEW
591
            )));
×
592
        }
593
        RunPolicy::Ask => {
NEW
594
            if !is_tty {
×
NEW
595
                return Err(cmd::Error::from(format!(
×
NEW
596
                    "plugin `{id}` requires approval. Run `jp plugin install {id}` first, or set \
×
NEW
597
                     plugins.command.{id}.run = \"unattended\" in config."
×
NEW
598
                )));
×
NEW
599
            }
×
600

NEW
601
            let mut writer = std::io::stderr();
×
NEW
602
            drop(writeln!(
×
NEW
603
                writer,
×
604
                "  \u{2192} Plugin `{id}` found in registry."
605
            ));
NEW
606
            let options = vec![
×
NEW
607
                InlineOption::new('y', "install and run"),
×
NEW
608
                InlineOption::new('n', "cancel"),
×
609
            ];
NEW
610
            let answer = InlineSelect::new("Install and run it?", options)
×
NEW
611
                .prompt(&mut writer)
×
NEW
612
                .map_err(|e| cmd::Error::from(format!("prompt failed: {e}")))?;
×
613

NEW
614
            if answer != 'y' {
×
NEW
615
                return Err(cmd::Error::from("plugin execution cancelled"));
×
NEW
616
            }
×
617
        }
NEW
618
        RunPolicy::Unattended => {}
×
619
    }
620

NEW
621
    drop(writeln!(
×
NEW
622
        std::io::stderr(),
×
623
        "  \u{2192} Installing jp-{id} for {target}..."
624
    ));
NEW
625
    let client = reqwest::Client::new();
×
NEW
626
    let data = registry::download_and_verify(&client, binary_info).await?;
×
627

NEW
628
    let path = registry::install_binary(id, &data)?;
×
NEW
629
    drop(writeln!(
×
NEW
630
        std::io::stderr(),
×
631
        "  \u{2192} Installed to {path}",
632
    ));
633

634
    // Verify against pinned checksum if configured.
NEW
635
    verify_checksum(id, &path, plugin_cfg)?;
×
636

NEW
637
    Ok(Some(path))
×
NEW
638
}
×
639

640
/// Check run policy for a `$PATH`-discovered plugin.
NEW
641
fn check_run_policy(
×
NEW
642
    name: &str,
×
NEW
643
    binary_path: &Utf8Path,
×
NEW
644
    plugin_cfg: Option<&CommandPluginConfig>,
×
NEW
645
    is_tty: bool,
×
NEW
646
) -> Result<(), cmd::Error> {
×
647
    // Verify pinned checksum first.
NEW
648
    verify_checksum(name, binary_path, plugin_cfg)?;
×
649

NEW
650
    let run_policy = plugin_cfg.and_then(|c| c.run).unwrap_or(RunPolicy::Ask);
×
651

NEW
652
    match run_policy {
×
NEW
653
        RunPolicy::Unattended => Ok(()),
×
NEW
654
        RunPolicy::Deny => Err(cmd::Error::from(format!(
×
NEW
655
            "plugin `{name}` is denied by configuration"
×
NEW
656
        ))),
×
657
        RunPolicy::Ask => {
NEW
658
            if !is_tty {
×
NEW
659
                return Err(cmd::Error::from(format!(
×
NEW
660
                    "plugin `jp-{name}` found on $PATH but requires approval. Set \
×
NEW
661
                     plugins.command.{name}.run = \"unattended\" in config, or run `jp {name}` in \
×
NEW
662
                     a terminal."
×
NEW
663
                )));
×
NEW
664
            }
×
665

666
            // Check existing permanent approvals.
NEW
667
            if let Some(approvals) = registry::load_approvals()
×
NEW
668
                && let Some(approved) = approvals.approved.get(name)
×
NEW
669
                && approved.path == binary_path
×
NEW
670
                && registry::sha256_file(binary_path).is_ok_and(|sha| sha == approved.sha256)
×
671
            {
NEW
672
                debug!(name, %binary_path, "Plugin previously approved.");
×
NEW
673
                return Ok(());
×
NEW
674
            }
×
675

NEW
676
            let mut writer = std::io::stderr();
×
NEW
677
            drop(writeln!(
×
NEW
678
                writer,
×
679
                "  \u{2192} Found jp-{name} on $PATH ({binary_path})",
680
            ));
NEW
681
            let options = vec![
×
NEW
682
                InlineOption::new('y', "run this time"),
×
NEW
683
                InlineOption::new('Y', "run and remember permanently"),
×
NEW
684
                InlineOption::new('n', "deny"),
×
685
            ];
NEW
686
            let answer = InlineSelect::new("Run it?", options)
×
NEW
687
                .prompt(&mut writer)
×
NEW
688
                .map_err(|e| cmd::Error::from(format!("prompt failed: {e}")))?;
×
689

NEW
690
            match answer {
×
NEW
691
                'y' => Ok(()),
×
692
                'Y' => {
NEW
693
                    registry::save_approval(name, binary_path)?;
×
NEW
694
                    Ok(())
×
695
                }
NEW
696
                _ => Err(cmd::Error::from("plugin execution denied")),
×
697
            }
698
        }
699
    }
NEW
700
}
×
701

702
/// Send a `Describe` request to a plugin and return its metadata.
703
///
704
/// Spawns the binary, sends `{"type":"describe"}`, reads one response line,
705
/// and returns the parsed [`DescribeResponse`]. Returns `None` if the plugin
706
/// doesn't support describe or fails to respond.
NEW
707
pub(crate) fn describe_plugin(binary: &Utf8Path) -> Option<DescribeResponse> {
×
NEW
708
    let mut child = Command::new(binary)
×
NEW
709
        .stdin(Stdio::piped())
×
NEW
710
        .stdout(Stdio::piped())
×
NEW
711
        .stderr(Stdio::null())
×
NEW
712
        .spawn()
×
NEW
713
        .ok()?;
×
714

NEW
715
    let mut child_stdin = child.stdin.take()?;
×
NEW
716
    let child_stdout = child.stdout.take()?;
×
717

718
    // Send describe request.
NEW
719
    let json = serde_json::to_string(&HostToPlugin::Describe).ok()?;
×
NEW
720
    writeln!(child_stdin, "{json}").ok()?;
×
NEW
721
    child_stdin.flush().ok()?;
×
NEW
722
    drop(child_stdin); // Signal no more messages.
×
723

724
    // Read one line response.
NEW
725
    let mut reader = BufReader::new(child_stdout);
×
NEW
726
    let mut line = String::new();
×
NEW
727
    reader.read_line(&mut line).ok()?;
×
728

NEW
729
    drop(child.wait());
×
730

NEW
731
    if line.trim().is_empty() {
×
NEW
732
        return None;
×
NEW
733
    }
×
734

NEW
735
    let msg: PluginToHost = serde_json::from_str(line.trim()).ok()?;
×
NEW
736
    match msg {
×
NEW
737
        PluginToHost::Describe(resp) => Some(resp),
×
NEW
738
        _ => None,
×
739
    }
NEW
740
}
×
741

742
/// Discover plugin binaries on `$PATH` and in the user-local install directory.
743
///
744
/// Returns `(subcommand_name, binary_path)` pairs, sorted by name.
745
/// For a binary named `jp-serve`, the subcommand name is `serve`.
746
/// Installed plugins take priority over `$PATH` duplicates.
NEW
747
pub(crate) fn discover_plugins() -> Vec<(String, Utf8PathBuf)> {
×
NEW
748
    let path_var = std::env::var_os("PATH").unwrap_or_default();
×
NEW
749
    let mut seen = HashSet::new();
×
NEW
750
    let mut plugins = Vec::new();
×
751

752
    // Scan install directory first so installed plugins take priority.
NEW
753
    if let Some(bin_dir) = registry::bin_dir() {
×
NEW
754
        scan_dir_for_plugins(&bin_dir, &mut seen, &mut plugins);
×
NEW
755
    }
×
756

NEW
757
    for dir in std::env::split_paths(&path_var) {
×
NEW
758
        let Some(dir) = Utf8Path::from_path(&dir) else {
×
NEW
759
            continue;
×
760
        };
761

NEW
762
        scan_dir_for_plugins(dir, &mut seen, &mut plugins);
×
763
    }
764

NEW
765
    plugins.sort_by(|a, b| a.0.cmp(&b.0));
×
NEW
766
    plugins
×
NEW
767
}
×
768

NEW
769
fn scan_dir_for_plugins(
×
NEW
770
    dir: &Utf8Path,
×
NEW
771
    seen: &mut HashSet<String>,
×
NEW
772
    plugins: &mut Vec<(String, Utf8PathBuf)>,
×
NEW
773
) {
×
NEW
774
    let Ok(entries) = dir.read_dir_utf8() else {
×
NEW
775
        return;
×
776
    };
NEW
777
    for entry in entries.flatten() {
×
NEW
778
        let name = entry.file_name();
×
NEW
779
        let Some(subcommand) = name.strip_prefix("jp-") else {
×
NEW
780
            continue;
×
781
        };
782

783
        // On Windows, strip the .exe extension.
784
        #[cfg(windows)]
785
        let subcommand = subcommand.strip_suffix(".exe").unwrap_or(subcommand);
786

787
        // On Unix, skip non-executable files.
788
        #[cfg(unix)]
789
        {
790
            use std::os::unix::fs::PermissionsExt as _;
NEW
791
            let Ok(meta) = entry.metadata() else {
×
NEW
792
                continue;
×
793
            };
NEW
794
            if meta.permissions().mode() & 0o111 == 0 {
×
NEW
795
                continue;
×
NEW
796
            }
×
797
        }
798

NEW
799
        if seen.insert(subcommand.to_owned()) {
×
NEW
800
            plugins.push((subcommand.to_owned(), entry.into_path()));
×
NEW
801
        }
×
802
    }
NEW
803
}
×
804

805
/// Show a plugin's help text via the `Describe` protocol.
NEW
806
pub(crate) fn show_plugin_help(binary: &Utf8Path) -> cmd::Output {
×
NEW
807
    match describe_plugin(binary) {
×
NEW
808
        Some(desc) => {
×
NEW
809
            let mut out = std::io::stdout().lock();
×
NEW
810
            if let Some(help) = &desc.help {
×
NEW
811
                drop(writeln!(out, "{help}"));
×
NEW
812
            } else {
×
NEW
813
                drop(writeln!(out, "{}: {}", desc.name, desc.description));
×
NEW
814
            }
×
NEW
815
            Ok(())
×
816
        }
NEW
817
        None => Err(cmd::Error::from("plugin does not support describe")),
×
818
    }
NEW
819
}
×
820

821
/// Dispatch an external plugin subcommand.
822
///
823
/// Resolves the plugin binary, then runs the protocol loop. Called from
824
/// `Commands::run()` after the normal startup flow.
NEW
825
pub(crate) async fn run_external(args: &[String], ctx: &Ctx) -> cmd::Output {
×
NEW
826
    let (subcommand, plugin_args) = args
×
NEW
827
        .split_first()
×
NEW
828
        .ok_or_else(|| cmd::Error::from("no subcommand provided for plugin dispatch"))?;
×
829

830
    // Handle help without downloading or approval.
NEW
831
    if plugin_args.iter().any(|a| a == "-h" || a == "--help") {
×
NEW
832
        let binary = find_any_plugin_binary(subcommand).ok_or_else(|| {
×
NEW
833
            cmd::Error::from(format!(
×
834
                "plugin `{subcommand}` not found. No installed plugin or `jp-{subcommand}` binary \
835
                 found on $PATH.",
836
            ))
NEW
837
        })?;
×
NEW
838
        return show_plugin_help(&binary);
×
NEW
839
    }
×
840

NEW
841
    let config = ctx.config();
×
NEW
842
    let binary = resolve_plugin_binary(subcommand, &config.plugins, ctx.term.is_tty).await?;
×
843

NEW
844
    debug!(%binary, subcommand, "Dispatching to plugin.");
×
845

NEW
846
    run_plugin(
×
NEW
847
        &binary,
×
NEW
848
        plugin_args,
×
NEW
849
        &ctx.workspace,
×
NEW
850
        &config,
×
NEW
851
        &ctx.signals,
×
NEW
852
        ctx.term.args.verbose,
×
NEW
853
    )?;
×
NEW
854
    Ok(())
×
NEW
855
}
×
856

857
#[cfg(test)]
858
#[path = "dispatch_tests.rs"]
859
mod tests;
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc