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

dcdpr / jp / 29150514163

11 Jul 2026 11:09AM UTC coverage: 68.819%. First build
29150514163

Pull #866

github

web-flow
Merge d64cbd385 into e6d2ef0f5
Pull Request #866: feat(cli, workspace): Add `jp workspace` and RFD 087 bootstrap

980 of 1337 new or added lines in 22 files covered. (73.3%)

40474 of 58812 relevant lines covered (68.82%)

668.23 hits per line

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

9.87
/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::SignalRouter};
40

41
/// Run a plugin binary, handling the full protocol lifecycle.
42
///
43
/// `binary` is the path to the plugin executable.
44
/// `args` are the remaining CLI arguments to forward.
45
/// `child_cwd` is the bootstrap-resolved working directory for the child (RFD
46
/// 087): `Some` when JP operates on a workspace other than the launch cwd's
47
/// own, `None` to inherit the process cwd.
48
pub(crate) fn run_plugin(
×
49
    name: &str,
×
50
    binary: &Utf8Path,
×
51
    args: &[String],
×
52
    workspace: &Workspace,
×
NEW
53
    child_cwd: Option<&Utf8Path>,
×
54
    storage_path: Option<&Utf8Path>,
×
55
    user_storage_path: Option<&Utf8Path>,
×
56
    config: &Arc<AppConfig>,
×
57
    signals: &SignalRouter,
×
58
    log_level: u8,
×
59
) -> Result<(), cmd::Error> {
×
60
    let config_json = serde_json::to_value(config.as_ref().to_partial())
×
61
        .map_err(|e| cmd::Error::from(format!("failed to serialize config: {e}")))?;
×
62

63
    let options: serde_json::Map<String, Value> = config
×
64
        .plugins
×
65
        .command
×
66
        .get(name)
×
67
        .and_then(|c| c.options.as_ref())
×
68
        .and_then(Value::as_object)
×
69
        .cloned()
×
70
        .unwrap_or_default();
×
71

72
    let storage_path = storage_path.ok_or("workspace has no storage configured")?;
×
73

74
    let home = std::env::home_dir().and_then(|p| camino::Utf8PathBuf::from_path_buf(p).ok());
×
75

76
    let init = HostToPlugin::Init(InitMessage {
×
77
        version: PROTOCOL_VERSION,
×
78
        workspace: WorkspaceInfo {
×
79
            root: workspace.root().to_owned(),
×
80
            storage: storage_path.to_owned(),
×
81
            id: workspace.id().to_string(),
×
82
        },
×
83
        paths: PathsInfo {
×
84
            user_data: jp_workspace::user_data_dir().ok(),
×
85
            user_config: jp_config::fs::user_global_config_dir(home.as_deref()),
×
86
            user_workspace: user_storage_path.map(ToOwned::to_owned),
×
87
        },
×
88
        config: config_json.clone(),
×
89
        options,
×
90
        args: args.to_vec(),
×
91
        log_level,
×
92
    });
×
93

94
    debug!(%binary, "Spawning plugin.");
×
95

96
    let mut cmd = Command::new(binary);
×
97
    cmd.stdin(Stdio::piped())
×
98
        .stdout(Stdio::piped())
×
99
        .stderr(Stdio::piped());
×
100

101
    // The root-as-working-directory invariant (RFD 087): when JP operates on
102
    // a workspace other than the launch cwd's own, plugins run as if launched
103
    // from the selected workspace root.
NEW
104
    if let Some(cwd) = child_cwd {
×
NEW
105
        cmd.current_dir(cwd);
×
NEW
106
    }
×
107

108
    // Prevent the child from receiving SIGINT/SIGTERM directly. The host
109
    // sends `Shutdown` over the protocol instead, giving the plugin a
110
    // chance to exit gracefully.
111
    #[cfg(unix)]
112
    {
113
        use std::os::unix::process::CommandExt as _;
114
        cmd.process_group(0);
×
115
    }
116

117
    let mut child = cmd
×
118
        .spawn()
×
119
        .map_err(|e| cmd::Error::from(format!("failed to spawn plugin: {e}")))?;
×
120

121
    let child_stdin = child.stdin.take().expect("stdin piped");
×
122
    let stdout = child.stdout.take().expect("stdout piped");
×
123
    let stderr = child.stderr.take().expect("stderr piped");
×
124

125
    // Wrap stdin so the shutdown thread can write to it too.
126
    let stdin = Arc::new(Mutex::new(child_stdin));
×
127

128
    // Forward stderr to tracing in a background thread.
129
    let stderr_handle = thread::spawn(move || {
×
130
        let reader = BufReader::new(stderr);
×
131
        for line in reader.lines() {
×
132
            match line {
×
133
                Ok(line) => trace!(target: "plugin::stderr", "{}", line),
×
134
                Err(e) => {
×
135
                    warn!("Error reading plugin stderr: {e}");
×
136
                    break;
×
137
                }
138
            }
139
        }
140
    });
×
141

142
    // Shutdown thread: sends `Shutdown` directly to the plugin's stdin when
143
    // an interrupt or a graceful shutdown request arrives. If the plugin
144
    // doesn't exit within the grace period, sends SIGKILL.
145
    //
146
    // The guard drops when this function returns; the thread then sees the
147
    // notification channel close and exits.
148
    let (_interrupt_guard, mut interrupt_rx) = signals.push_handler();
×
149
    let shutdown_token = signals.shutdown_token();
×
150
    let shutdown_sent = Arc::new(AtomicBool::new(false));
×
151
    let shutdown_writer = stdin.clone();
×
152
    let shutdown_flag = shutdown_sent.clone();
×
153
    let child_id = child.id();
×
154
    let shutdown_handle = thread::spawn(move || {
×
155
        let interrupted = futures::executor::block_on(async {
×
156
            tokio::select! {
×
157
                notified = interrupt_rx.recv() => notified.is_some(),
×
158
                () = shutdown_token.cancelled() => true,
×
159
            }
160
        });
×
161

162
        // The plugin run completed and deregistered its handler.
163
        if !interrupted {
×
164
            return;
×
165
        }
×
166

167
        // Send Shutdown over the protocol.
168
        if let Ok(mut writer) = shutdown_writer.lock() {
×
169
            drop(write_message(&mut *writer, &HostToPlugin::Shutdown));
×
170
        }
×
171
        shutdown_flag.store(true, Ordering::Release);
×
172

173
        // Grace period: wait in short intervals so we don't block cleanup
174
        // if the plugin exits promptly.
175
        for _ in 0..50 {
×
176
            thread::sleep(std::time::Duration::from_millis(100));
×
177
            if !is_process_alive(child_id) {
×
178
                return;
×
179
            }
×
180
        }
181

182
        kill_child(child_id);
×
183
    });
×
184

185
    // Send init.
186
    {
187
        let mut writer = stdin.lock().expect("stdin lock poisoned");
×
188
        write_message(&mut *writer, &init)
×
189
            .map_err(|e| cmd::Error::from(format!("failed to send init: {e}")))?;
×
190
    }
191

192
    // Read messages from plugin.
193
    let reader = BufReader::new(stdout);
×
194
    let result = message_loop(reader, &stdin, workspace, &config_json, &shutdown_sent);
×
195

196
    // Always clean up, even on error.
197
    drop(child.wait());
×
198
    drop(stderr_handle.join());
×
199
    drop(shutdown_handle);
×
200

201
    result
×
202
}
×
203

204
/// The main message loop: reads plugin requests and sends responses.
205
fn message_loop(
1✔
206
    reader: BufReader<impl std::io::Read>,
1✔
207
    stdin: &Mutex<impl Write>,
1✔
208
    workspace: &Workspace,
1✔
209
    config_json: &Value,
1✔
210
    shutdown_sent: &AtomicBool,
1✔
211
) -> Result<(), cmd::Error> {
1✔
212
    for line in reader.lines() {
2✔
213
        let line =
2✔
214
            line.map_err(|e| cmd::Error::from(format!("failed to read from plugin: {e}")))?;
2✔
215

216
        if line.trim().is_empty() {
2✔
217
            continue;
×
218
        }
2✔
219

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

223
        trace!(?msg, "Received plugin message.");
2✔
224

225
        let mut writer = stdin.lock().expect("stdin lock poisoned");
2✔
226

227
        match msg {
2✔
228
            PluginToHost::Ready => {
229
                debug!("Plugin signaled ready.");
1✔
230
            }
231

232
            PluginToHost::ListConversations(req) => {
×
233
                let response = handle_list_conversations(workspace, req.id);
×
234
                write_message(&mut *writer, &response)?;
×
235
            }
236

237
            PluginToHost::ReadEvents(req) => {
×
238
                let response = handle_read_events(workspace, &req.conversation, req.id);
×
239
                write_message(&mut *writer, &response)?;
×
240
            }
241

242
            PluginToHost::ReadConfig(req) => {
×
243
                let response = handle_read_config(config_json, req.path, req.id);
×
244
                write_message(&mut *writer, &response)?;
×
245
            }
246

247
            PluginToHost::Print(print) => {
×
248
                // In Phase 1, write to stdout directly. Full printer
×
249
                // integration comes later when we thread through &Printer.
×
250
                let stdout = std::io::stdout();
×
251
                let mut handle = stdout.lock();
×
252
                drop(handle.write_all(print.text.as_bytes()));
×
253
                drop(handle.flush());
×
254
            }
×
255

256
            PluginToHost::Log(log) => {
×
257
                emit_log(&log);
×
258
            }
×
259

260
            PluginToHost::Describe(_) => {
261
                debug!("Ignoring describe in message loop.");
×
262
            }
263

264
            PluginToHost::Exit(exit) => {
1✔
265
                debug!(code = exit.code, "Plugin exited.");
1✔
266
                if exit.code == 0 {
1✔
267
                    return Ok(());
1✔
268
                }
×
269
                return match exit.reason {
×
270
                    Some(reason) => Err(cmd::Error::from((exit.code, reason))),
×
271
                    None => Err(cmd::Error::from(exit.code)),
×
272
                };
273
            }
274
        }
275
    }
276

277
    // Plugin's stdout closed without an `exit` message. If we sent a
278
    // shutdown, this is expected (the child exited after receiving it).
279
    if shutdown_sent.load(Ordering::Acquire) {
×
280
        debug!("Plugin exited after shutdown.");
×
281
        return Ok(());
×
282
    }
×
283

284
    error!("Plugin exited without sending exit message.");
×
285
    Err(cmd::Error::from((
×
286
        1u8,
×
287
        "plugin exited unexpectedly without sending exit message",
×
288
    )))
×
289
}
1✔
290

291
fn handle_list_conversations(workspace: &Workspace, req_id: Option<String>) -> HostToPlugin {
×
292
    let data: Vec<ConversationSummary> = workspace
×
293
        .conversations()
×
294
        .map(|(id, meta)| ConversationSummary {
×
295
            id: id.as_deciseconds().to_string(),
×
296
            title: meta.title.clone(),
×
297
            last_activated_at: meta.last_activated_at,
×
298
            events_count: meta.events_count,
×
299
        })
×
300
        .collect();
×
301

302
    HostToPlugin::Conversations(ConversationsResponse { id: req_id, data })
×
303
}
×
304

305
fn handle_read_events(
×
306
    workspace: &Workspace,
×
307
    conversation_id: &str,
×
308
    req_id: Option<String>,
×
309
) -> HostToPlugin {
×
310
    let conv_id = match jp_conversation::ConversationId::try_from_deciseconds_str(conversation_id) {
×
311
        Ok(id) => id,
×
312
        Err(e) => {
×
313
            return HostToPlugin::Error(ErrorResponse {
×
314
                id: req_id,
×
315
                request: Some("read_events".to_owned()),
×
316
                message: format!("invalid conversation ID: {e}"),
×
317
            });
×
318
        }
319
    };
320

321
    let handle = match workspace.acquire_conversation(&conv_id) {
×
322
        Ok(h) => h,
×
323
        Err(e) => {
×
324
            return HostToPlugin::Error(ErrorResponse {
×
325
                id: req_id,
×
326
                request: Some("read_events".to_owned()),
×
327
                message: format!("conversation not found: {e}"),
×
328
            });
×
329
        }
330
    };
331

332
    let events = match workspace.events(&handle) {
×
333
        Ok(stream) => stream,
×
334
        Err(e) => {
×
335
            return HostToPlugin::Error(ErrorResponse {
×
336
                id: req_id,
×
337
                request: Some("read_events".to_owned()),
×
338
                message: format!("failed to load events: {e}"),
×
339
            });
×
340
        }
341
    };
342

343
    // Serialize events to JSON values, then decode base64-encoded storage
344
    // fields so plugins receive plain text.
345
    let (_, mut event_values) = match events.to_parts() {
×
346
        Ok(parts) => parts,
×
347
        Err(e) => {
×
348
            return HostToPlugin::Error(ErrorResponse {
×
349
                id: req_id,
×
350
                request: Some("read_events".to_owned()),
×
351
                message: format!("failed to serialize events: {e}"),
×
352
            });
×
353
        }
354
    };
355

356
    for value in &mut event_values {
×
357
        jp_conversation::decode_event_value(value);
×
358
    }
×
359

360
    HostToPlugin::Events(EventsResponse {
×
361
        id: req_id,
×
362
        conversation: conversation_id.to_owned(),
×
363
        data: event_values,
×
364
    })
×
365
}
×
366

367
fn handle_read_config(
3✔
368
    config_json: &Value,
3✔
369
    path: Option<String>,
3✔
370
    req_id: Option<String>,
3✔
371
) -> HostToPlugin {
3✔
372
    let data = match &path {
3✔
373
        Some(path) => {
2✔
374
            let mut current = config_json;
2✔
375
            for segment in path.split('.') {
3✔
376
                match current.get(segment) {
3✔
377
                    Some(v) => current = v,
2✔
378
                    None => {
379
                        return HostToPlugin::Error(ErrorResponse {
1✔
380
                            id: req_id,
1✔
381
                            request: Some("read_config".to_owned()),
1✔
382
                            message: format!("config path not found: {path}"),
1✔
383
                        });
1✔
384
                    }
385
                }
386
            }
387
            current.clone()
1✔
388
        }
389
        None => config_json.clone(),
1✔
390
    };
391

392
    HostToPlugin::Config(ConfigResponse {
2✔
393
        id: req_id,
2✔
394
        path,
2✔
395
        data,
2✔
396
    })
2✔
397
}
3✔
398

399
fn emit_log(log: &LogMessage) {
×
400
    match log.level.as_str() {
×
401
        "trace" => trace!(target: "plugin", message = %log.message),
×
402
        "debug" => debug!(target: "plugin", message = %log.message),
×
403
        "info" => tracing::info!(target: "plugin", message = %log.message),
×
404
        "warn" => warn!(target: "plugin", message = %log.message),
×
405
        "error" => error!(target: "plugin", message = %log.message),
×
406
        _ => {
407
            warn!(target: "plugin", level = %log.level, message = %log.message, "unknown log level");
×
408
        }
409
    }
410
}
×
411

412
fn write_message(writer: &mut impl Write, msg: &HostToPlugin) -> Result<(), cmd::Error> {
×
413
    let json = serde_json::to_string(msg)
×
414
        .map_err(|e| cmd::Error::from(format!("failed to serialize message: {e}")))?;
×
415
    writeln!(writer, "{json}")
×
416
        .map_err(|e| cmd::Error::from(format!("failed to write to plugin stdin: {e}")))?;
×
417
    writer
×
418
        .flush()
×
419
        .map_err(|e| cmd::Error::from(format!("failed to flush plugin stdin: {e}")))?;
×
420
    Ok(())
×
421
}
×
422

423
/// Check if a process is still alive by PID.
424
#[cfg(unix)]
425
fn is_process_alive(pid: u32) -> bool {
×
426
    // kill with signal 0 checks existence without sending a signal.
427
    unsafe { libc::kill(libc::pid_t::from(pid.cast_signed()), 0) == 0 }
×
428
}
×
429

430
#[cfg(windows)]
431
fn is_process_alive(pid: u32) -> bool {
432
    use windows_sys::Win32::{
433
        Foundation::{CloseHandle, STILL_ACTIVE},
434
        System::Threading::{GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION},
435
    };
436

437
    unsafe {
438
        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
439
        if handle.is_null() {
440
            return false;
441
        }
442
        let mut exit_code: u32 = 0;
443
        let ok = GetExitCodeProcess(handle, &mut exit_code);
444
        CloseHandle(handle);
445
        ok != 0 && (exit_code as i32) == STILL_ACTIVE
446
    }
447
}
448

449
/// Send SIGKILL to a child process by PID.
450
///
451
/// Used as a last resort when the plugin doesn't exit within the grace period
452
/// after receiving `Shutdown`.
453
#[cfg(unix)]
454
fn kill_child(pid: u32) {
×
455
    // SAFETY: We're sending a signal to a process we spawned.
456
    unsafe {
×
457
        libc::kill(libc::pid_t::from(pid.cast_signed()), libc::SIGKILL);
×
458
    }
×
459
    debug!(pid, "Sent SIGKILL to plugin after grace period.");
×
460
}
×
461

462
#[cfg(windows)]
463
fn kill_child(pid: u32) {
464
    use windows_sys::Win32::{
465
        Foundation::CloseHandle,
466
        System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess},
467
    };
468

469
    // SAFETY: We're terminating a process we spawned.
470
    unsafe {
471
        let handle = OpenProcess(PROCESS_TERMINATE, 0, pid);
472
        if !handle.is_null() {
473
            TerminateProcess(handle, 1);
474
            CloseHandle(handle);
475
        }
476
    }
477
    debug!(pid, "Sent TerminateProcess to plugin after grace period.");
478
}
479

480
/// Search `$PATH` for a plugin binary matching the given subcommand segments.
481
///
482
/// For `["serve"]`, looks for `jp-serve`.
483
/// For `["conversation", "export"]`, looks for `jp-conversation-export`.
484
pub(crate) fn find_plugin_binary(segments: &[&str]) -> Option<Utf8PathBuf> {
1✔
485
    let name = format!("jp-{}", segments.join("-"));
1✔
486
    which::which(&name)
1✔
487
        .ok()
1✔
488
        .and_then(|p| Utf8PathBuf::from_path_buf(p).ok())
1✔
489
}
1✔
490

491
/// Find any existing plugin binary without downloading or prompting.
492
///
493
/// Checks the install directory first, then `$PATH`.
494
/// Used for non-mutating operations like help requests.
495
pub(crate) fn find_any_plugin_binary(name: &str) -> Option<Utf8PathBuf> {
×
496
    if let Some(path) = registry::find_installed(name) {
×
497
        return Some(path);
×
498
    }
×
499
    let segments: Vec<&str> = name.split('-').collect();
×
500
    find_plugin_binary(&segments)
×
501
}
×
502

503
/// Resolve a plugin binary through multiple sources:
504
///
505
/// 1. User-local install directory (previously installed plugins)
506
/// 2. Plugin registry (auto-install if official, prompt if third-party)
507
/// 3. `$PATH` (with approval check for unapproved plugins)
508
///
509
/// The `plugins_config` drives installation and execution policy.
510
/// Per-plugin settings override the defaults from the registry (official vs
511
/// third-party).
512
pub(crate) async fn resolve_plugin_binary(
×
513
    name: &str,
×
514
    plugins_config: &PluginsConfig,
×
515
    is_tty: bool,
×
516
) -> Result<Option<Utf8PathBuf>, cmd::Error> {
×
517
    let plugin_cfg = plugins_config.command.get(name);
×
518

519
    // Explicit deny in config.
520
    if plugin_cfg.is_some_and(|c| c.run == Some(RunPolicy::Deny)) {
×
521
        return Err(cmd::Error::from(format!(
×
522
            "plugin `{name}` is denied by configuration (plugins.command.{name}.run = \"deny\")"
×
523
        )));
×
524
    }
×
525

526
    // 1. Already installed locally.
527
    if let Some(path) = registry::find_installed(name) {
×
528
        debug!(name, %path, "Found installed plugin.");
×
529
        verify_checksum(name, &path, plugin_cfg)?;
×
530
        return Ok(Some(path));
×
531
    }
×
532

533
    // 2. Check registry.
534
    if let Some(path) = try_registry_install(name, plugins_config, is_tty).await? {
×
535
        return Ok(Some(path));
×
536
    }
×
537

538
    // 3. Check $PATH with run policy.
539
    let segments: Vec<&str> = name.split('-').collect();
×
540
    if let Some(path) = find_plugin_binary(&segments) {
×
541
        check_run_policy(name, &path, plugin_cfg, is_tty)?;
×
542
        return Ok(Some(path));
×
543
    }
×
544

545
    Ok(None)
×
546
}
×
547

548
/// Verify a binary's checksum against the config-pinned value, if any.
549
fn verify_checksum(
×
550
    name: &str,
×
551
    binary_path: &Utf8Path,
×
552
    plugin_cfg: Option<&CommandPluginConfig>,
×
553
) -> Result<(), cmd::Error> {
×
554
    let Some(checksum) = plugin_cfg.and_then(|c| c.checksum.as_ref()) else {
×
555
        return Ok(());
×
556
    };
557

558
    let actual = registry::sha256_file(binary_path)?;
×
559
    if actual != checksum.value {
×
560
        return Err(cmd::Error::from(format!(
×
561
            "plugin `{name}` binary checksum mismatch.\nexpected: {}\nactual:   {actual}\nThe \
×
562
             binary at {binary_path} has changed since it was pinned. Update \
×
563
             plugins.command.{name}.checksum.value in your config to accept the new binary.",
×
564
            checksum.value,
×
565
        )));
×
566
    }
×
567

568
    Ok(())
×
569
}
×
570

571
/// Try to install a plugin from the cached registry.
572
async fn try_registry_install(
×
573
    name: &str,
×
574
    plugins_config: &PluginsConfig,
×
575
    is_tty: bool,
×
576
) -> Result<Option<Utf8PathBuf>, cmd::Error> {
×
577
    let Some(reg) = registry::load_cached() else {
×
578
        return Ok(None);
×
579
    };
580

581
    // Find the registry entry whose `id` matches the requested name.
582
    // In Phase 5, this will use the command path (registry key) for
583
    // multi-segment routing. For now, we match on `id`.
584
    let Some(plugin) = reg.plugins.values().find(|p| p.id == name) else {
×
585
        return Ok(None);
×
586
    };
587

588
    // Only handle command plugins.
589
    let jp_plugin::registry::PluginKind::Command { ref binaries, .. } = plugin.kind else {
×
590
        return Ok(None);
×
591
    };
592

593
    let target = registry::current_target();
×
594
    let Some(binary_info) = binaries.get(&target) else {
×
595
        return Ok(None);
×
596
    };
597

598
    let id = &plugin.id;
×
599
    let plugin_cfg = plugins_config.command.get(id);
×
600

601
    // Check if auto-install is allowed.
602
    let auto_install = plugin_cfg
×
603
        .and_then(|c| c.install)
×
604
        .unwrap_or(plugins_config.auto_install);
×
605

606
    if !auto_install && !plugin.official {
×
607
        return Ok(None);
×
608
    }
×
609

610
    // Determine run policy: config > registry default.
611
    let run_policy = plugin_cfg
×
612
        .and_then(|c| c.run)
×
613
        .unwrap_or(if plugin.official {
×
614
            RunPolicy::Unattended
×
615
        } else {
616
            RunPolicy::Ask
×
617
        });
618

619
    match run_policy {
×
620
        RunPolicy::Deny => {
621
            return Err(cmd::Error::from(format!(
×
622
                "plugin `{id}` is denied by configuration"
×
623
            )));
×
624
        }
625
        RunPolicy::Ask => {
626
            if !is_tty {
×
627
                return Err(cmd::Error::from(format!(
×
628
                    "plugin `{id}` requires approval. Run `jp plugin install {id}` first, or set \
×
629
                     plugins.command.{id}.run = \"unattended\" in config."
×
630
                )));
×
631
            }
×
632

633
            let mut writer = std::io::stderr();
×
634
            drop(writeln!(
×
635
                writer,
×
636
                "  \u{2192} Plugin `{id}` found in registry."
637
            ));
638
            let options = vec![
×
639
                InlineOption::new('y', "install and run"),
×
640
                InlineOption::new('n', "cancel"),
×
641
            ];
642
            let answer = InlineSelect::new("Install and run it?", options)
×
643
                .prompt(&mut writer)
×
644
                .map_err(|e| cmd::Error::from(format!("prompt failed: {e}")))?;
×
645

646
            if answer != 'y' {
×
647
                return Err(cmd::Error::from("plugin execution cancelled"));
×
648
            }
×
649
        }
650
        RunPolicy::Unattended => {}
×
651
    }
652

653
    drop(writeln!(
×
654
        std::io::stderr(),
×
655
        "  \u{2192} Installing jp-{id} for {target}..."
656
    ));
657
    let client = reqwest::Client::new();
×
658
    let data = registry::download_and_verify(&client, binary_info).await?;
×
659

660
    let path = registry::install_binary(id, &data)?;
×
661
    drop(writeln!(
×
662
        std::io::stderr(),
×
663
        "  \u{2192} Installed to {path}",
664
    ));
665

666
    // Verify against pinned checksum if configured.
667
    verify_checksum(id, &path, plugin_cfg)?;
×
668

669
    Ok(Some(path))
×
670
}
×
671

672
/// Check run policy for a `$PATH`-discovered plugin.
673
fn check_run_policy(
×
674
    name: &str,
×
675
    binary_path: &Utf8Path,
×
676
    plugin_cfg: Option<&CommandPluginConfig>,
×
677
    is_tty: bool,
×
678
) -> Result<(), cmd::Error> {
×
679
    // Verify pinned checksum first.
680
    verify_checksum(name, binary_path, plugin_cfg)?;
×
681

682
    let run_policy = plugin_cfg.and_then(|c| c.run).unwrap_or(RunPolicy::Ask);
×
683

684
    match run_policy {
×
685
        RunPolicy::Unattended => Ok(()),
×
686
        RunPolicy::Deny => Err(cmd::Error::from(format!(
×
687
            "plugin `{name}` is denied by configuration"
×
688
        ))),
×
689
        RunPolicy::Ask => {
690
            if !is_tty {
×
691
                return Err(cmd::Error::from(format!(
×
692
                    "plugin `jp-{name}` found on $PATH but requires approval. Set \
×
693
                     plugins.command.{name}.run = \"unattended\" in config, or run `jp {name}` in \
×
694
                     a terminal."
×
695
                )));
×
696
            }
×
697

698
            // Check existing permanent approvals.
699
            if let Some(approvals) = registry::load_approvals()
×
700
                && let Some(approved) = approvals.approved.get(name)
×
701
                && approved.path == binary_path
×
702
                && registry::sha256_file(binary_path).is_ok_and(|sha| sha == approved.sha256)
×
703
            {
704
                debug!(name, %binary_path, "Plugin previously approved.");
×
705
                return Ok(());
×
706
            }
×
707

708
            let mut writer = std::io::stderr();
×
709
            drop(writeln!(
×
710
                writer,
×
711
                "  \u{2192} Found jp-{name} on $PATH ({binary_path})",
712
            ));
713
            let options = vec![
×
714
                InlineOption::new('y', "run this time"),
×
715
                InlineOption::new('Y', "run and remember permanently"),
×
716
                InlineOption::new('n', "deny"),
×
717
            ];
718
            let answer = InlineSelect::new("Run it?", options)
×
719
                .prompt(&mut writer)
×
720
                .map_err(|e| cmd::Error::from(format!("prompt failed: {e}")))?;
×
721

722
            match answer {
×
723
                'y' => Ok(()),
×
724
                'Y' => {
725
                    registry::save_approval(name, binary_path)?;
×
726
                    Ok(())
×
727
                }
728
                _ => Err(cmd::Error::from("plugin execution denied")),
×
729
            }
730
        }
731
    }
732
}
×
733

734
/// Send a `Describe` request to a plugin and return its metadata.
735
///
736
/// Spawns the binary, sends `{"type":"describe"}`, reads one response line, and
737
/// returns the parsed [`DescribeResponse`].
738
/// Returns `None` if the plugin doesn't support describe or fails to respond.
739
pub(crate) fn describe_plugin(binary: &Utf8Path) -> Option<DescribeResponse> {
×
740
    let mut child = Command::new(binary)
×
741
        .stdin(Stdio::piped())
×
742
        .stdout(Stdio::piped())
×
743
        .stderr(Stdio::null())
×
744
        .spawn()
×
745
        .ok()?;
×
746

747
    let mut child_stdin = child.stdin.take()?;
×
748
    let child_stdout = child.stdout.take()?;
×
749

750
    // Send describe request.
751
    let json = serde_json::to_string(&HostToPlugin::Describe).ok()?;
×
752
    writeln!(child_stdin, "{json}").ok()?;
×
753
    child_stdin.flush().ok()?;
×
754
    drop(child_stdin); // Signal no more messages.
×
755

756
    // Read one line response.
757
    let mut reader = BufReader::new(child_stdout);
×
758
    let mut line = String::new();
×
759
    reader.read_line(&mut line).ok()?;
×
760

761
    drop(child.wait());
×
762

763
    if line.trim().is_empty() {
×
764
        return None;
×
765
    }
×
766

767
    let msg: PluginToHost = serde_json::from_str(line.trim()).ok()?;
×
768
    match msg {
×
769
        PluginToHost::Describe(resp) => Some(resp),
×
770
        _ => None,
×
771
    }
772
}
×
773

774
/// Discover plugin binaries on `$PATH` and in the user-local install directory.
775
///
776
/// Returns `(subcommand_name, binary_path)` pairs, sorted by name.
777
/// For a binary named `jp-serve`, the subcommand name is `serve`.
778
/// Installed plugins take priority over `$PATH` duplicates.
779
pub(crate) fn discover_plugins() -> Vec<(String, Utf8PathBuf)> {
×
780
    let path_var = std::env::var_os("PATH").unwrap_or_default();
×
781
    let mut seen = HashSet::new();
×
782
    let mut plugins = Vec::new();
×
783

784
    // Scan install directory first so installed plugins take priority.
785
    if let Some(bin_dir) = registry::bin_dir() {
×
786
        scan_dir_for_plugins(&bin_dir, &mut seen, &mut plugins);
×
787
    }
×
788

789
    for dir in std::env::split_paths(&path_var) {
×
790
        let Some(dir) = Utf8Path::from_path(&dir) else {
×
791
            continue;
×
792
        };
793

794
        scan_dir_for_plugins(dir, &mut seen, &mut plugins);
×
795
    }
796

797
    plugins.sort_by(|a, b| a.0.cmp(&b.0));
×
798
    plugins
×
799
}
×
800

801
fn scan_dir_for_plugins(
×
802
    dir: &Utf8Path,
×
803
    seen: &mut HashSet<String>,
×
804
    plugins: &mut Vec<(String, Utf8PathBuf)>,
×
805
) {
×
806
    let Ok(entries) = dir.read_dir_utf8() else {
×
807
        return;
×
808
    };
809
    for entry in entries.flatten() {
×
810
        let name = entry.file_name();
×
811
        let Some(subcommand) = name.strip_prefix("jp-") else {
×
812
            continue;
×
813
        };
814

815
        // On Windows, strip the .exe extension.
816
        #[cfg(windows)]
817
        let subcommand = subcommand.strip_suffix(".exe").unwrap_or(subcommand);
818

819
        // On Unix, skip non-executable files.
820
        #[cfg(unix)]
821
        {
822
            use std::os::unix::fs::PermissionsExt as _;
823
            let Ok(meta) = entry.metadata() else {
×
824
                continue;
×
825
            };
826
            if meta.permissions().mode() & 0o111 == 0 {
×
827
                continue;
×
828
            }
×
829
        }
830

831
        if seen.insert(subcommand.to_owned()) {
×
832
            plugins.push((subcommand.to_owned(), entry.into_path()));
×
833
        }
×
834
    }
835
}
×
836

837
/// Show a plugin's help text via the `Describe` protocol.
838
pub(crate) fn show_plugin_help(binary: &Utf8Path) -> cmd::Output {
×
839
    match describe_plugin(binary) {
×
840
        Some(desc) => {
×
841
            let mut out = std::io::stdout().lock();
×
842
            if let Some(help) = &desc.help {
×
843
                drop(writeln!(out, "{help}"));
×
844
            } else {
×
845
                drop(writeln!(out, "{}: {}", desc.name, desc.description));
×
846
            }
×
847
            Ok(())
×
848
        }
849
        None => Err(cmd::Error::from("plugin does not support describe")),
×
850
    }
851
}
×
852

853
/// Produce a clap-formatted error for an unknown subcommand.
854
///
855
/// Uses `Command::error()` to get clap's standard error chrome (colored
856
/// `error:` prefix, usage line, help hint).
857
/// The message includes our plugin-specific context.
858
/// Returns exit code 2 (clap's convention for usage errors) with no message,
859
/// since the output was already written.
860
fn unknown_subcommand_error(name: &str) -> cmd::Error {
×
861
    use clap::CommandFactory as _;
862

863
    let mut cmd = crate::Cli::command();
×
864
    let err = cmd.error(
×
865
        clap::error::ErrorKind::InvalidSubcommand,
×
866
        format!(
×
867
            "unrecognized subcommand '{name}'\n\n  No built-in command, registry plugin, or \
868
             `jp-{name}` binary found on $PATH."
869
        ),
870
    );
871
    drop(err.print());
×
872
    cmd::Error::from(2u8)
×
873
}
×
874

875
/// Dispatch an external plugin subcommand.
876
///
877
/// Resolves the plugin binary, then runs the protocol loop.
878
/// Called from `Commands::run()` after the normal startup flow.
879
pub(crate) async fn run_external(args: &[String], ctx: &Ctx) -> cmd::Output {
×
880
    let (subcommand, plugin_args) = args
×
881
        .split_first()
×
882
        .ok_or("no subcommand provided for plugin dispatch")?;
×
883

884
    // Handle help without downloading or approval.
885
    if plugin_args.iter().any(|a| a == "-h" || a == "--help") {
×
886
        let binary = find_any_plugin_binary(subcommand).ok_or_else(|| {
×
887
            cmd::Error::from(format!(
×
888
                "plugin `{subcommand}` not found. No installed plugin or `jp-{subcommand}` binary \
889
                 found on $PATH.",
890
            ))
891
        })?;
×
892
        return show_plugin_help(&binary);
×
893
    }
×
894

895
    let config = ctx.config();
×
896
    let Some(binary) = resolve_plugin_binary(subcommand, &config.plugins, ctx.term.is_tty).await?
×
897
    else {
898
        return Err(unknown_subcommand_error(subcommand));
×
899
    };
900

901
    debug!(%binary, subcommand, "Dispatching to plugin.");
×
902

903
    run_plugin(
×
904
        subcommand,
×
905
        &binary,
×
906
        plugin_args,
×
907
        &ctx.workspace,
×
NEW
908
        ctx.exec.child_cwd(),
×
909
        ctx.storage_path(),
×
910
        ctx.user_storage_path(),
×
911
        &config,
×
912
        &ctx.signals,
×
913
        ctx.term.args.verbose,
×
914
    )?;
×
915
    Ok(())
×
916
}
×
917

918
#[cfg(test)]
919
#[path = "dispatch_tests.rs"]
920
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