Skip to content

Commit dcb6e76

Browse files
author
anduimagui
committed
feat: add package-based agent loading from npm packages
1 parent 4fb2bc5 commit dcb6e76

2 files changed

Lines changed: 211 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,8 @@ export function Autocomplete(props: {
187187
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
188188
.map(
189189
(agent): AutocompleteOption => ({
190-
display: "@" + agent.name,
190+
// Avoid double @ for scoped package agents (e.g., @openpets/coder/pr-review)
191+
display: agent.name.startsWith("@") ? agent.name : "@" + agent.name,
191192
onSelect: () => {
192193
insertPart(agent.name, {
193194
type: "agent",

packages/opencode/src/config/config.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\.md$/, "").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(/\.md$/, "").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

Comments
 (0)