@@ -103,7 +103,16 @@ export namespace Config {
103103 result . agent = mergeDeep ( result . agent , await loadAgent ( dir ) )
104104 result . agent = mergeDeep ( result . agent , await loadMode ( dir ) )
105105 result . plugin . push ( ...( await loadPlugin ( dir ) ) )
106+
107+ // Load agents from npm packages in node_modules
108+ const nodeModulesDir = path . join ( dir , "node_modules" )
109+ result . agent = mergeDeep ( result . agent , await loadPackageAgents ( nodeModulesDir ) )
106110 }
111+
112+ // Also scan project root node_modules for package agents
113+ const projectNodeModules = path . join ( Instance . worktree , "node_modules" )
114+ result . agent = mergeDeep ( result . agent , await loadPackageAgents ( projectNodeModules ) )
115+
107116 await Promise . allSettled ( promises )
108117
109118 // Migrate deprecated mode field to agent field
@@ -303,6 +312,206 @@ export namespace Config {
303312 return plugins
304313 }
305314
315+ const PACKAGE_PROMPT_GLOB = new Bun . Glob ( "prompts/**/*.md" )
316+ const PACKAGE_AGENT_GLOB = new Bun . Glob ( "agent/**/*.md" )
317+
318+ /**
319+ * Load agents from installed npm packages that contain prompts/ or agent/ directories.
320+ * Scans node_modules for packages with prompt files (e.g., @openpets/gitlab).
321+ */
322+ export async function loadPackageAgents ( nodeModulesDir : string ) : Promise < Record < string , Agent > > {
323+ const result : Record < string , Agent > = { }
324+
325+ const nodeModulesPath = path . resolve ( nodeModulesDir )
326+ const dirExists = await fs
327+ . stat ( nodeModulesPath )
328+ . then ( ( s ) => s . isDirectory ( ) )
329+ . catch ( ( ) => false )
330+ if ( ! dirExists ) {
331+ return result
332+ }
333+
334+ // Scan for packages with prompts
335+ const packages = await discoverPromptPackages ( nodeModulesPath )
336+
337+ for ( const pkg of packages ) {
338+ const agents = await loadAgentsFromPackage ( pkg )
339+ for ( const [ name , agent ] of Object . entries ( agents ) ) {
340+ result [ name ] = agent
341+ }
342+ }
343+
344+ return result
345+ }
346+
347+ interface PromptPackage {
348+ name : string
349+ path : string
350+ promptsDir ?: string
351+ agentDir ?: string
352+ }
353+
354+ async function discoverPromptPackages ( nodeModulesPath : string ) : Promise < PromptPackage [ ] > {
355+ const packages : PromptPackage [ ] = [ ]
356+
357+ const entries = await fs . readdir ( nodeModulesPath , { withFileTypes : true } ) . catch ( ( ) => [ ] )
358+
359+ for ( const entry of entries ) {
360+ const entryPath = path . join ( nodeModulesPath , entry . name )
361+ // Check if it's a directory (following symlinks since bun uses symlinks in node_modules)
362+ const isDir = await fs
363+ . stat ( entryPath )
364+ . then ( ( s ) => s . isDirectory ( ) )
365+ . catch ( ( ) => false )
366+ if ( ! isDir ) continue
367+
368+ // Handle scoped packages (@scope/package)
369+ if ( entry . name . startsWith ( "@" ) ) {
370+ const scopePath = entryPath
371+ const scopedEntries = await fs . readdir ( scopePath , { withFileTypes : true } ) . catch ( ( ) => [ ] )
372+
373+ for ( const scopedEntry of scopedEntries ) {
374+ const scopedPath = path . join ( scopePath , scopedEntry . name )
375+ // Check if it's a directory (following symlinks)
376+ const isScopedDir = await fs
377+ . stat ( scopedPath )
378+ . then ( ( s ) => s . isDirectory ( ) )
379+ . catch ( ( ) => false )
380+ if ( ! isScopedDir ) continue
381+
382+ const pkg = await checkPackageForPrompts ( scopedPath , `${ entry . name } /${ scopedEntry . name } ` )
383+ if ( pkg ) packages . push ( pkg )
384+ }
385+ } else {
386+ // Regular package
387+ const pkg = await checkPackageForPrompts ( entryPath , entry . name )
388+ if ( pkg ) packages . push ( pkg )
389+ }
390+ }
391+
392+ return packages
393+ }
394+
395+ async function checkPackageForPrompts ( pkgPath : string , pkgName : string ) : Promise < PromptPackage | null > {
396+ const pkgJsonPath = path . join ( pkgPath , "package.json" )
397+ const pkgJsonFile = Bun . file ( pkgJsonPath )
398+
399+ if ( ! ( await pkgJsonFile . exists ( ) ) ) return null
400+
401+ const pkgJson = await pkgJsonFile . json ( ) . catch ( ( ) => null )
402+ if ( ! pkgJson ) return null
403+
404+ // Check if package has prompts/** or agent/** in files array
405+ const files : string [ ] = pkgJson . files || [ ]
406+ const hasPrompts = files . some ( ( f : string ) => f . startsWith ( "prompts/" ) || f === "prompts/**/*" )
407+ const hasAgents = files . some ( ( f : string ) => f . startsWith ( "agent/" ) || f === "agent/**/*" )
408+
409+ // Also check for actual directories
410+ const promptsDir = path . join ( pkgPath , "prompts" )
411+ const agentDir = path . join ( pkgPath , "agent" )
412+
413+ const promptsDirExists = await fs
414+ . stat ( promptsDir )
415+ . then ( ( ) => true )
416+ . catch ( ( ) => false )
417+ const agentDirExists = await fs
418+ . stat ( agentDir )
419+ . then ( ( ) => true )
420+ . catch ( ( ) => false )
421+
422+ if ( ! hasPrompts && ! hasAgents && ! promptsDirExists && ! agentDirExists ) return null
423+
424+ return {
425+ name : pkgName ,
426+ path : pkgPath ,
427+ promptsDir : promptsDirExists ? promptsDir : undefined ,
428+ agentDir : agentDirExists ? agentDir : undefined ,
429+ }
430+ }
431+
432+ async function loadAgentsFromPackage ( pkg : PromptPackage ) : Promise < Record < string , Agent > > {
433+ const result : Record < string , Agent > = { }
434+
435+ // Load from prompts/ directory
436+ if ( pkg . promptsDir ) {
437+ for await ( const item of PACKAGE_PROMPT_GLOB . scan ( {
438+ absolute : true ,
439+ followSymlinks : true ,
440+ dot : true ,
441+ cwd : pkg . path ,
442+ } ) ) {
443+ const agent = await parsePackagePromptFile ( item , pkg )
444+ if ( agent ) {
445+ result [ agent . name ] = agent . config
446+ }
447+ }
448+ }
449+
450+ // Load from agent/ directory (standard opencode format)
451+ if ( pkg . agentDir ) {
452+ for await ( const item of PACKAGE_AGENT_GLOB . scan ( {
453+ absolute : true ,
454+ followSymlinks : true ,
455+ dot : true ,
456+ cwd : pkg . path ,
457+ } ) ) {
458+ const md = await ConfigMarkdown . parse ( item )
459+ if ( ! md . data ) continue
460+
461+ const relativePath = path . relative ( pkg . agentDir , item )
462+ const agentName = relativePath . replace ( / \. m d $ / , "" ) . replace ( / \/ / g, "/" )
463+ const prefixedName = `${ pkg . name } /${ agentName } `
464+
465+ const config = {
466+ name : prefixedName ,
467+ ...md . data ,
468+ prompt : md . content . trim ( ) ,
469+ }
470+ const parsed = Agent . safeParse ( config )
471+ if ( parsed . success ) {
472+ result [ prefixedName ] = parsed . data
473+ }
474+ }
475+ }
476+
477+ return result
478+ }
479+
480+ async function parsePackagePromptFile (
481+ filePath : string ,
482+ pkg : PromptPackage ,
483+ ) : Promise < { name : string ; config : Agent } | null > {
484+ const md = await ConfigMarkdown . parse ( filePath )
485+
486+ // Get relative path from prompts/ directory
487+ const relativePath = path . relative ( pkg . promptsDir ! , filePath )
488+ const baseName = relativePath . replace ( / \. m d $ / , "" ) . replace ( / \/ / g, "/" )
489+
490+ // Skip README files
491+ if ( baseName . toLowerCase ( ) === "readme" ) return null
492+
493+ // Create agent name with package prefix (e.g., @openpets/gitlab/pr-review)
494+ const agentName = `${ pkg . name } /${ baseName } `
495+
496+ // Extract frontmatter or use defaults
497+ const frontmatter = md . data || { }
498+
499+ const config : Agent = {
500+ description : frontmatter . description || `Prompt from ${ pkg . name } : ${ baseName } ` ,
501+ prompt : md . content . trim ( ) ,
502+ mode : frontmatter . mode || "subagent" ,
503+ ...frontmatter ,
504+ }
505+
506+ const parsed = Agent . safeParse ( config )
507+ if ( ! parsed . success ) {
508+ log . warn ( "invalid package prompt" , { path : filePath , errors : parsed . error } )
509+ return null
510+ }
511+
512+ return { name : agentName , config : parsed . data }
513+ }
514+
306515 export const McpLocal = z
307516 . object ( {
308517 type : z . literal ( "local" ) . describe ( "Type of MCP server connection" ) ,
0 commit comments