@@ -223,6 +223,99 @@ async function readPluginMetadataOnly(
223223 } ;
224224}
225225
226+ // JS entry filenames a plugin directory may carry. A directory with one of
227+ // these can export its manifest inline (no manifest.json), so presence alone
228+ // marks the entry as plugin-shaped — see loadPluginEntryMetadata.
229+ const PLUGIN_JS_ENTRY_CANDIDATES = [ "src/index.ts" , "index.ts" , "index.js" ] ;
230+
231+ /**
232+ * No-import metadata probe for explicit add-by-path: reports whether
233+ * `entryPath` resolves to something a post-consent `loadPluginEntry` could
234+ * load, without executing any plugin code. Returns a metadata-only stub when
235+ * plugin-shaped, null when bogus (missing/unreadable, or nothing loadable).
236+ * Manifest-backed and data-only layouts resolve their manifest now; a JS
237+ * entry with no manifest.json may still export an inline manifest, so its
238+ * presence alone counts — the full load after the trust grant re-derives the
239+ * manifest and reports the same user-facing errors. Exported so the /plugins
240+ * UI can grant path trust before the first import.
241+ */
242+ export async function loadPluginEntryMetadata (
243+ entryPath : string ,
244+ opts : {
245+ cwd ?: string ;
246+ onWarning ?: ( msg : string ) => void ;
247+ diagnostics ?: PluginLoadDiagnostics ;
248+ origin : PluginOrigin ;
249+ } ,
250+ ) : Promise < PluginModule | null > {
251+ const cwd = opts . cwd ?? process . cwd ( ) ;
252+ // Prefer diagnostics collector; else explicit onWarning; else stderrPluginWarning.
253+ const onWarning = resolvePluginWarningHandler (
254+ opts . diagnostics !== undefined
255+ ? { diagnostics : opts . diagnostics }
256+ : opts . onWarning !== undefined
257+ ? { onWarning : opts . onWarning }
258+ : { onWarning : stderrPluginWarning } ,
259+ ) ;
260+ let dir = entryPath ;
261+ try {
262+ const info = await stat ( entryPath ) ;
263+ if ( ! info . isDirectory ( ) ) dir = dirname ( entryPath ) ;
264+ } catch {
265+ // Path missing or unreadable — not a plugin.
266+ return null ;
267+ }
268+ const abs = resolve ( dir ) ;
269+ const manifest =
270+ ( await readManifestJson ( abs , onWarning ) ) ??
271+ ( await readClaudeFormatManifestJson (
272+ join ( abs , ".claude-plugin" ) ,
273+ onWarning ,
274+ ) ) ;
275+ if ( manifest !== null ) {
276+ return {
277+ dir : abs ,
278+ manifest,
279+ origin : opts . origin ,
280+ pluginPath : abs ,
281+ metadataOnly : true ,
282+ } ;
283+ }
284+ // Manifest-less data-only layout (agents/commands/skills markdown): no JS
285+ // to import, so resolving it now is safe.
286+ const dataOnly = await loadDataOnlyPlugin ( abs , {
287+ cwd,
288+ ...( opts . diagnostics !== undefined
289+ ? { diagnostics : opts . diagnostics }
290+ : { onWarning } ) ,
291+ } ) ;
292+ if ( dataOnly !== null ) {
293+ return {
294+ dir : abs ,
295+ manifest : dataOnly . manifest ,
296+ origin : opts . origin ,
297+ pluginPath : abs ,
298+ metadataOnly : true ,
299+ } ;
300+ }
301+ // JS entry whose manifest may be an inline export — knowable only by
302+ // importing, which happens after the trust grant.
303+ for ( const candidate of PLUGIN_JS_ENTRY_CANDIDATES ) {
304+ try {
305+ await stat ( join ( abs , candidate ) ) ;
306+ return {
307+ dir : abs ,
308+ origin : opts . origin ,
309+ pluginPath : abs ,
310+ metadataOnly : true ,
311+ } ;
312+ } catch {
313+ // Candidate missing; try the next index filename.
314+ }
315+ }
316+ return null ;
317+ }
318+
226319// Attempt to load a single plugin directory entry (a file or a directory with
227320// an index file). Returns null if the entry cannot be resolved to a module.
228321// Exported so the /plugins UI can register a plugin from an arbitrary path.
@@ -265,7 +358,7 @@ export async function loadPluginEntry(
265358 if ( info . isDirectory ( ) ) {
266359 pluginDir = entryPath ;
267360 // Prefer src/index.ts, then index.ts, then index.js.
268- for ( const candidate of [ "src/index.ts" , "index.ts" , "index.js" ] ) {
361+ for ( const candidate of PLUGIN_JS_ENTRY_CANDIDATES ) {
269362 const candidatePath = join ( entryPath , candidate ) ;
270363 try {
271364 await stat ( candidatePath ) ;
0 commit comments