@@ -10,6 +10,7 @@ import { parsePluginManifest, type PluginManifest } from "./manifest.js";
1010import { loadDataOnlyPlugin } from "./data-only.js" ;
1111import {
1212 resolvePluginWarningHandler ,
13+ stderrPluginWarning ,
1314 type PluginLoadDiagnostics ,
1415} from "./diagnostics.js" ;
1516import {
@@ -116,9 +117,15 @@ export async function loadPluginEntry(
116117 } = { } ,
117118) : Promise < PluginModule | null > {
118119 const cwd = opts . cwd ?? process . cwd ( ) ;
119- // Prefer diagnostics collector when provided so batch discovery can summarize;
120- // explicit onWarning is for tests / one-off sinks; default is stderr per line.
121- const onWarning = resolvePluginWarningHandler ( opts ) ;
120+ // Prefer diagnostics collector so batch discovery can summarize; explicit
121+ // onWarning for tests; else stderrPluginWarning.
122+ const onWarning = resolvePluginWarningHandler (
123+ opts . diagnostics !== undefined
124+ ? { diagnostics : opts . diagnostics }
125+ : opts . onWarning !== undefined
126+ ? { onWarning : opts . onWarning }
127+ : { onWarning : stderrPluginWarning } ,
128+ ) ;
122129 const origin = opts . origin ;
123130 let target = entryPath ;
124131 let pluginDir = entryPath ;
@@ -255,16 +262,53 @@ export type ExpandPluginPathOptions = {
255262 * tree is allowed — multi-level relatives ok).
256263 */
257264 containRoot ?: string ;
258- /** Called for each skipped marketplace source (never silent). */
259- onSkip ?: ( skip : ExpandPluginPathSkip ) => void ;
265+ /**
266+ * Called for each skipped marketplace source (never silent). Required —
267+ * not optional with a stderr default — because an optional sink with a
268+ * silent fallback is exactly the shape that let three review rounds each
269+ * turn up one more call site writing raw stderr mid-frame in the
270+ * interactive TUI (CL-5411). Making it required turns every call site
271+ * into a compile error until it picks a handler on purpose:
272+ * `expandSkipDiagnosticsHandler(diagnostics)` for a batching caller,
273+ * an explicit stderr writer for a headless caller where that is correct
274+ * and visible (see `src/exec/runner.ts`), or `() => {}` to state on the
275+ * record that a caller is deliberately ignoring skips.
276+ */
277+ onSkip : ( skip : ExpandPluginPathSkip ) => void ;
260278} ;
261279
262- /** Default skip reporter: stderr, same shape as Claude discovery . */
263- function defaultExpandSkip ( skip : ExpandPluginPathSkip ) : void {
280+ /** One-line description of a skip, shared by every `onSkip` sink (diagnostics or stderr) . */
281+ export function formatExpandSkip ( skip : ExpandPluginPathSkip ) : string {
264282 const where = skip . resolved !== undefined ? ` → ${ skip . resolved } ` : "" ;
265- process . stderr . write (
266- `plugins: skipped marketplace source ${ JSON . stringify ( skip . source ) } (${ skip . reason } )${ where } \n` ,
267- ) ;
283+ return `marketplace source ${ JSON . stringify ( skip . source ) } skipped (${ skip . reason } )${ where } ` ;
284+ }
285+
286+ /**
287+ * The ordinary `onSkip` handler: collect into `diagnostics` instead of
288+ * writing raw stderr, so a skipped marketplace member lands in the same
289+ * end-of-batch summary as every other plugin-load warning.
290+ */
291+ export function expandSkipDiagnosticsHandler (
292+ diagnostics : PluginLoadDiagnostics ,
293+ ) : ( skip : ExpandPluginPathSkip ) => void {
294+ return ( skip ) => diagnostics . warnings . push ( formatExpandSkip ( skip ) ) ;
295+ }
296+
297+ /**
298+ * `onSkip` when no diagnostics collector is in play: one explicit stderr
299+ * line, module-private and only reached by an internal caller's own
300+ * deliberate choice (see `resolveExpandSkip` below) — never `expandPluginPath`
301+ * falling back to it on its own.
302+ */
303+ function stderrExpandSkip ( skip : ExpandPluginPathSkip ) : void {
304+ process . stderr . write ( `plugins: ${ formatExpandSkip ( skip ) } \n` ) ;
305+ }
306+
307+ /** Diagnostics when given, else the explicit stderr line — no silent option. */
308+ function resolveExpandSkip (
309+ diagnostics ?: PluginLoadDiagnostics ,
310+ ) : ( skip : ExpandPluginPathSkip ) => void {
311+ return diagnostics !== undefined ? expandSkipDiagnosticsHandler ( diagnostics ) : stderrExpandSkip ;
268312}
269313
270314/**
@@ -313,11 +357,11 @@ function defaultContainRoot(marketplaceRoot: string): string {
313357
314358export async function expandPluginPath (
315359 path : string ,
316- opts : ExpandPluginPathOptions = { } ,
360+ opts : ExpandPluginPathOptions ,
317361) : Promise < string [ ] > {
318362 const marketplaceRoot = resolve ( path ) ;
319363 const report = ( skip : ExpandPluginPathSkip ) : void => {
320- ( opts . onSkip ?? defaultExpandSkip ) ( skip ) ;
364+ opts . onSkip ( skip ) ;
321365 } ;
322366
323367 // 1. Declared marketplace: relative `source` list, contained under containRoot
@@ -424,13 +468,17 @@ export async function expandPluginPath(
424468// Resolve a registered pluginPaths entry to the member plugin directories that
425469// exist on disk: relative entries resolve against cwd, marketplace roots expand
426470// to their members. Missing paths are dropped so trust decisions made from this
427- // list never pre-grant a directory that could appear later with other content.
471+ // list never pre-grant a directory that could appear later with other content
472+ // — that drop is deliberate, but a *skipped* member (bad source, escape) still
473+ // has a reason worth reaching the caller's diagnostics, so `onSkip` is
474+ // required rather than silently defaulted (see `ExpandPluginPathOptions`).
428475export async function expandExistingPluginMembers (
429476 registeredPath : string ,
430477 cwd : string ,
478+ onSkip : ( skip : ExpandPluginPathSkip ) => void ,
431479) : Promise < string [ ] > {
432480 const abs = isAbsolute ( registeredPath ) ? registeredPath : resolve ( cwd , registeredPath ) ;
433- const members = await expandPluginPath ( abs ) ;
481+ const members = await expandPluginPath ( abs , { onSkip } ) ;
434482 const existing = await Promise . all ( members . map ( ( m ) => pathExists ( m ) ) ) ;
435483 return members . filter ( ( _ , i ) => existing [ i ] ) ;
436484}
@@ -456,8 +504,10 @@ async function scanPluginsDir(
456504
457505 const results : PluginModule [ ] = [ ] ;
458506 for ( const entry of entries ) {
459- // Each entry may itself be a marketplace, so expand before loading.
460- const dirs = await expandPluginPath ( join ( dir , entry ) ) ;
507+ // Each entry may itself be a marketplace, so expand before loading. A
508+ // skipped member routes into `diagnostics` when the caller has one, same
509+ // as loadPluginEntry below — otherwise it would bypass the collector.
510+ const dirs = await expandPluginPath ( join ( dir , entry ) , { onSkip : resolveExpandSkip ( diagnostics ) } ) ;
461511 for ( const d of dirs ) {
462512 const abs = resolve ( d ) ;
463513 if ( originRequiresTrust ( origin ) && isTrusted !== undefined && ! isTrusted ( abs ) ) {
@@ -536,10 +586,13 @@ export async function loadPluginsFromPaths(
536586 diagnostics ?: PluginLoadDiagnostics ;
537587 } = { } ,
538588) : Promise < PluginModule [ ] > {
589+ // A skipped member routes into `diagnostics` when the caller has one, same
590+ // reasoning as scanPluginsDir — otherwise it bypasses the collector.
591+ const onSkip = resolveExpandSkip ( opts . diagnostics ) ;
539592 const resolved = await Promise . all (
540593 paths . map ( async ( p ) => {
541594 const abs = isAbsolute ( p ) ? p : join ( cwd , p ) ;
542- return expandPluginPath ( abs ) ;
595+ return expandPluginPath ( abs , { onSkip } ) ;
543596 } ) ,
544597 ) ;
545598 // Anything under <cwd>/.corbits/plugins/ is project origin no matter how it
@@ -611,12 +664,12 @@ export async function discoverClaudeInstalledPlugins(
611664 try {
612665 parsed = JSON . parse ( raw ) ;
613666 } catch {
614- // Prefer collector when present; else one stderr line (default sink) .
667+ // Prefer collector when present; else stderrPluginWarning .
615668 resolvePluginWarningHandler (
616- opts . diagnostics !== undefined ? { diagnostics : opts . diagnostics } : { } ,
617- ) (
618- `failed to parse ${ registryPath } ` ,
619- ) ;
669+ opts . diagnostics !== undefined
670+ ? { diagnostics : opts . diagnostics }
671+ : { onWarning : stderrPluginWarning } ,
672+ ) ( `failed to parse ${ registryPath } ` ) ;
620673 return [ ] ;
621674 }
622675 if ( typeof parsed !== "object" || parsed === null || Array . isArray ( parsed ) ) {
@@ -628,10 +681,12 @@ export async function discoverClaudeInstalledPlugins(
628681 }
629682
630683 const pluginsRoot = resolve ( home , ".claude" , "plugins" ) ;
631- // Default expand-skip sink respects diagnostics when provided so discovery
632- // can emit one summary ; explicit onExpandSkip (tests) still wins.
684+ // Default expand-skip sink: diagnostics when provided, else
685+ // stderrPluginWarning ; explicit onExpandSkip (tests) still wins.
633686 const warnExpand = resolvePluginWarningHandler (
634- opts . diagnostics !== undefined ? { diagnostics : opts . diagnostics } : { } ,
687+ opts . diagnostics !== undefined
688+ ? { diagnostics : opts . diagnostics }
689+ : { onWarning : stderrPluginWarning } ,
635690 ) ;
636691 const onExpandSkip =
637692 opts . onExpandSkip
0 commit comments