import { Log } from "../util/log"import path from "path"import { pathToFileURL } from "url"import os from "os"import z from "zod"import { Filesystem } from "../util/filesystem"import { ModelsDev } from "../provider/models"import { mergeDeep, pipe, unique } from "remeda"import { Global } from "../global"import fs from "fs/promises"import { lazy } from "../util/lazy"import { NamedError } from "@synsci/util/error"import { Flag } from "../flag/flag"import { Auth } from "../auth"import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc, printParseErrorCode,} from "jsonc-parser"import { Instance } from "../project/instance"import { LSPServer } from "../lsp/server"import { BunProc } from "@/bun"import { Installation } from "@/installation"import { ConfigMarkdown } from "./markdown"import { existsSync } from "fs"import { Bus } from "@/bus"import { GlobalBus } from "@/bus/global"import { Event } from "../server/event" export namespace Config { const log = Log.create({ service: "config" }) export const Scope = z.enum(["project", "global"]) export type Scope = z.infer<typeof Scope> // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function getManagedConfigDir(): string { const dir = (() => { switch (process.platform) { case "darwin": return "/Library/Application Support/openscience" case "win32": return path.join(process.env.ProgramData || "C:\\ProgramData", "openscience") default: return "/etc/openscience" } })() // Enterprise machines provisioned before the OpenScience rename may still // use the legacy "synsc" directory; keep honoring it until re-provisioned. if (existsSync(dir)) return dir const old = dir.replace(/openscience$/, "synsc") return existsSync(old) ? old : dir } const managedConfigDir = process.env.OPENSCIENCE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir() // Config filenames, oldest first: later merges win, so the legacy names load // as the base and openscience.json(c) overrides them. const CONFIG_FILES = ["synsc.jsonc", "synsc.json", "openscience.jsonc", "openscience.json"] // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) if (target.plugin && source.plugin) { merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) } if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } return merged } export const state = Instance.state(async () => { const auth = await Auth.all() // Load remote/well-known config first as the base layer (lowest precedence) // This allows organizations to provide default configs that users can override let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { process.env[value.key] = value.token log.debug("fetching remote config", { url: `${key}/.well-known/openscience` }) const response = await fetch(`${key}/.well-known/openscience`) if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file if (!remoteConfig.$schema) remoteConfig.$schema = "https://syntheticsciences.ai/config.json" result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/openscience`), ) log.debug("loaded remote config from well-known", { url: key }) } } // Global user config overrides remote config result = mergeConfigConcatArrays(result, await global()) // Custom config path overrides global if (Flag.OPENSCIENCE_CONFIG) { result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENSCIENCE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENSCIENCE_CONFIG }) } // Project config has highest precedence (overrides global and remote) if (!Flag.OPENSCIENCE_DISABLE_PROJECT_CONFIG) { for (const file of CONFIG_FILES) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { result = mergeConfigConcatArrays(result, await loadFile(resolved)) } } } // Inline config content has highest precedence if (Flag.OPENSCIENCE_CONFIG_CONTENT) { result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENSCIENCE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENSCIENCE_CONFIG_CONTENT") } result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] const directories = [ Global.Path.config, // Only scan project .openscience/ directories when project discovery is enabled // (".synsc" is the pre-rename name, still honored) ...(!Flag.OPENSCIENCE_DISABLE_PROJECT_CONFIG ? await Array.fromAsync( Filesystem.up({ targets: [".openscience", ".synsc"], start: Instance.directory, stop: Instance.worktree, }), ) : []), // Always scan ~/.openscience/ (user home directory) ...(await Array.fromAsync( Filesystem.up({ targets: [".openscience", ".synsc"], start: Global.Path.home, stop: Global.Path.home, }), )), ] if (Flag.OPENSCIENCE_CONFIG_DIR) { directories.push(Flag.OPENSCIENCE_CONFIG_DIR) log.debug("loading config from OPENSCIENCE_CONFIG_DIR", { path: Flag.OPENSCIENCE_CONFIG_DIR }) } for (const dir of unique(directories)) { if (dir.endsWith(".openscience") || dir.endsWith(".synsc") || dir === Flag.OPENSCIENCE_CONFIG_DIR) { for (const file of CONFIG_FILES) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) // to satisfy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] } } const exists = existsSync(path.join(dir, "node_modules")) const installing = installDependencies(dir) if (!exists) await installing result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) result.plugin.push(...(await loadPlugin(dir))) } // Load managed config files last (highest priority) - enterprise admin-controlled // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions // This way it only loads config file and not skills/plugins/commands if (existsSync(managedConfigDir)) { for (const file of CONFIG_FILES) { result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file))) } } // Load synced config from dashboard (highest priority after managed). // Written by OpenScience.syncServices() to the user's XDG config dir. Tolerate // a corrupt file: it must never brick config load (and thus the whole CLI). // Atomic writes prevent torn files going forward; this covers external // corruption or a file written by an older, non-atomic version. const syncedConfig = path.join(Global.Path.config, "openscience-synced.json") try { result = mergeConfigConcatArrays(result, await loadFile(syncedConfig)) } catch { // treat an unreadable synced config as absent } // Migrate deprecated mode field to agent field for (const [name, mode] of Object.entries(result.mode ?? {})) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { ...mode, mode: "primary" as const, }, }) } if (Flag.OPENSCIENCE_PERMISSION) { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENSCIENCE_PERMISSION)) } // Backwards compatibility: legacy top-level `tools` config if (result.tools) { const perms: Record<string, Config.PermissionAction> = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: Config.PermissionAction = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { perms.edit = action continue } perms[tool] = action } result.permission = mergeDeep(perms, result.permission ?? {}) } if (!result.username) result.username = os.userInfo().username if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) // Apply flag overrides for compaction settings if (Flag.OPENSCIENCE_DISABLE_AUTOCOMPACT) { result.compaction = { ...result.compaction, auto: false } } if (Flag.OPENSCIENCE_DISABLE_PRUNE) { result.compaction = { ...result.compaction, prune: false } } result.plugin = deduplicatePlugins(result.plugin ?? []) return { config: result, directories, } }) export async function installDependencies(dir: string) { const pkg = path.join(dir, "package.json") if (!(await Bun.file(pkg).exists())) { await Bun.write(pkg, "{}") } const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Bun.file(gitignore).exists() if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) await BunProc.run( ["add", "@synsci/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], { cwd: dir, }, ).catch(() => {}) // Install any additional dependencies defined in the package.json // This allows local plugins and custom tools to use external packages await BunProc.run(["install"], { cwd: dir }).catch(() => {}) } function rel(item: string, patterns: string[]) { for (const pattern of patterns) { const index = item.indexOf(pattern) if (index === -1) continue return item.slice(index + pattern.length) } } function trim(file: string) { const ext = path.extname(file) return ext.length ? file.slice(0, -ext.length) : file } const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md") async function loadCommand(dir: string) { const result: Record<string, Command> = {} for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) return undefined }) if (!md) continue const patterns = [ "/.openscience/command/", "/.openscience/commands/", "/.synsc/command/", "/.synsc/commands/", "/command/", "/commands/", ] const file = rel(item, patterns) ?? path.basename(item) const name = trim(file) const config = { name, ...md.data, template: md.content.trim(), } const parsed = Command.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) } return result } const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md") async function loadAgent(dir: string) { const result: Record<string, Agent> = {} for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) return undefined }) if (!md) continue const patterns = [ "/.openscience/agent/", "/.openscience/agents/", "/.synsc/agent/", "/.synsc/agents/", "/agent/", "/agents/", ] const file = rel(item, patterns) ?? path.basename(item) const agentName = trim(file) const config = { name: agentName, ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) } return result } const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md") async function loadMode(dir: string) { const result: Record<string, Agent> = {} for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) return undefined }) if (!md) continue const config = { name: path.basename(item, ".md"), ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = { ...parsed.data, mode: "primary" as const, } continue } } return result } const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}") async function loadPlugin(dir: string) { const plugins: string[] = [] for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { plugins.push(pathToFileURL(item).href) } return plugins } /** * Extracts a canonical plugin name from a plugin specifier. * - For file:// URLs: extracts filename without extension * - For npm packages: extracts package name without version * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" * getPluginName("oh-my-openscience@2.4.3") // "oh-my-openscience" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { return path.parse(new URL(plugin).pathname).name } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { return plugin.substring(0, lastAt) } return plugin } /** * Deduplicates plugins by name, with later entries (higher priority) winning. * Priority order (highest to lowest): * 1. Local plugin/ directory * 2. Local openscience.json * 3. Global plugin/ directory * 4. Global openscience.json * * Since plugins are added in low-to-high priority order, * we reverse, deduplicate (keeping first occurrence), then restore order. */ export function deduplicatePlugins(plugins: string[]): string[] { // seenNames: canonical plugin names for duplicate detection // e.g., "oh-my-openscience", "@scope/pkg" const seenNames = new Set<string>() // uniqueSpecifiers: full plugin specifiers to return // e.g., "oh-my-openscience@2.4.3", "file:///path/to/plugin.js" const uniqueSpecifiers: string[] = [] for (const specifier of plugins.toReversed()) { const name = getPluginName(specifier) if (!seenNames.has(name)) { seenNames.add(name) uniqueSpecifiers.push(specifier) } } return uniqueSpecifiers.toReversed() } export const McpLocal = z .object({ type: z.literal("local").describe("Type of MCP server connection"), command: z.string().array().describe("Command and arguments to run the MCP server"), environment: z .record(z.string(), z.string()) .optional() .describe("Environment variables to set when running the MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), timeout: z .number() .int() .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), }) .strict() .meta({Lines 1–500 of 1552. Load more
import { Log } from "../util/log" import path from "path" import { pathToFileURL } from "url" import os from "os" import z from "zod" import { Filesystem } from "../util/filesystem" import { ModelsDev } from "../provider/models" import { mergeDeep, pipe, unique } from "remeda" import { Global } from "../global" import fs from "fs/promises" import { lazy } from "../util/lazy" import { NamedError } from "@synsci/util/error" import { Flag } from "../flag/flag" import { Auth } from "../auth" import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc, printParseErrorCode, } from "jsonc-parser" import { Instance } from "../project/instance" import { LSPServer } from "../lsp/server" import { BunProc } from "@/bun" import { Installation } from "@/installation" import { ConfigMarkdown } from "./markdown" import { existsSync } from "fs" import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" export namespace Config { const log = Log.create({ service: "config" }) export const Scope = z.enum(["project", "global"]) export type Scope = z.infer<typeof Scope> // Managed settings directory for enterprise deployments (highest priority, admin-controlled) // These settings override all user and project settings function getManagedConfigDir(): string { const dir = (() => { switch (process.platform) { case "darwin": return "/Library/Application Support/openscience" case "win32": return path.join(process.env.ProgramData || "C:\\ProgramData", "openscience") default: return "/etc/openscience" } })() // Enterprise machines provisioned before the OpenScience rename may still // use the legacy "synsc" directory; keep honoring it until re-provisioned. if (existsSync(dir)) return dir const old = dir.replace(/openscience$/, "synsc") return existsSync(old) ? old : dir } const managedConfigDir = process.env.OPENSCIENCE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir() // Config filenames, oldest first: later merges win, so the legacy names load // as the base and openscience.json(c) overrides them. const CONFIG_FILES = ["synsc.jsonc", "synsc.json", "openscience.jsonc", "openscience.json"] // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) if (target.plugin && source.plugin) { merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin])) } if (target.instructions && source.instructions) { merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) } return merged } export const state = Instance.state(async () => { const auth = await Auth.all() // Load remote/well-known config first as the base layer (lowest precedence) // This allows organizations to provide default configs that users can override let result: Info = {} for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { process.env[value.key] = value.token log.debug("fetching remote config", { url: `${key}/.well-known/openscience` }) const response = await fetch(`${key}/.well-known/openscience`) if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file if (!remoteConfig.$schema) remoteConfig.$schema = "https://syntheticsciences.ai/config.json" result = mergeConfigConcatArrays( result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/openscience`), ) log.debug("loaded remote config from well-known", { url: key }) } } // Global user config overrides remote config result = mergeConfigConcatArrays(result, await global()) // Custom config path overrides global if (Flag.OPENSCIENCE_CONFIG) { result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENSCIENCE_CONFIG)) log.debug("loaded custom config", { path: Flag.OPENSCIENCE_CONFIG }) } // Project config has highest precedence (overrides global and remote) if (!Flag.OPENSCIENCE_DISABLE_PROJECT_CONFIG) { for (const file of CONFIG_FILES) { const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree) for (const resolved of found.toReversed()) { result = mergeConfigConcatArrays(result, await loadFile(resolved)) } } } // Inline config content has highest precedence if (Flag.OPENSCIENCE_CONFIG_CONTENT) { result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENSCIENCE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENSCIENCE_CONFIG_CONTENT") } result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] const directories = [ Global.Path.config, // Only scan project .openscience/ directories when project discovery is enabled // (".synsc" is the pre-rename name, still honored) ...(!Flag.OPENSCIENCE_DISABLE_PROJECT_CONFIG ? await Array.fromAsync( Filesystem.up({ targets: [".openscience", ".synsc"], start: Instance.directory, stop: Instance.worktree, }), ) : []), // Always scan ~/.openscience/ (user home directory) ...(await Array.fromAsync( Filesystem.up({ targets: [".openscience", ".synsc"], start: Global.Path.home, stop: Global.Path.home, }), )), ] if (Flag.OPENSCIENCE_CONFIG_DIR) { directories.push(Flag.OPENSCIENCE_CONFIG_DIR) log.debug("loading config from OPENSCIENCE_CONFIG_DIR", { path: Flag.OPENSCIENCE_CONFIG_DIR }) } for (const dir of unique(directories)) { if (dir.endsWith(".openscience") || dir.endsWith(".synsc") || dir === Flag.OPENSCIENCE_CONFIG_DIR) { for (const file of CONFIG_FILES) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) // to satisfy the type checker result.agent ??= {} result.mode ??= {} result.plugin ??= [] } } const exists = existsSync(path.join(dir, "node_modules")) const installing = installDependencies(dir) if (!exists) await installing result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadMode(dir)) result.plugin.push(...(await loadPlugin(dir))) } // Load managed config files last (highest priority) - enterprise admin-controlled // Kept separate from directories array to avoid write operations when installing plugins // which would fail on system directories requiring elevated permissions // This way it only loads config file and not skills/plugins/commands if (existsSync(managedConfigDir)) { for (const file of CONFIG_FILES) { result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file))) } } // Load synced config from dashboard (highest priority after managed). // Written by OpenScience.syncServices() to the user's XDG config dir. Tolerate // a corrupt file: it must never brick config load (and thus the whole CLI). // Atomic writes prevent torn files going forward; this covers external // corruption or a file written by an older, non-atomic version. const syncedConfig = path.join(Global.Path.config, "openscience-synced.json") try { result = mergeConfigConcatArrays(result, await loadFile(syncedConfig)) } catch { // treat an unreadable synced config as absent } // Migrate deprecated mode field to agent field for (const [name, mode] of Object.entries(result.mode ?? {})) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { ...mode, mode: "primary" as const, }, }) } if (Flag.OPENSCIENCE_PERMISSION) { result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENSCIENCE_PERMISSION)) } // Backwards compatibility: legacy top-level `tools` config if (result.tools) { const perms: Record<string, Config.PermissionAction> = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: Config.PermissionAction = enabled ? "allow" : "deny" if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { perms.edit = action continue } perms[tool] = action } result.permission = mergeDeep(perms, result.permission ?? {}) } if (!result.username) result.username = os.userInfo().username if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) // Apply flag overrides for compaction settings if (Flag.OPENSCIENCE_DISABLE_AUTOCOMPACT) { result.compaction = { ...result.compaction, auto: false } } if (Flag.OPENSCIENCE_DISABLE_PRUNE) { result.compaction = { ...result.compaction, prune: false } } result.plugin = deduplicatePlugins(result.plugin ?? []) return { config: result, directories, } }) export async function installDependencies(dir: string) { const pkg = path.join(dir, "package.json") if (!(await Bun.file(pkg).exists())) { await Bun.write(pkg, "{}") } const gitignore = path.join(dir, ".gitignore") const hasGitIgnore = await Bun.file(gitignore).exists() if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) await BunProc.run( ["add", "@synsci/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], { cwd: dir, }, ).catch(() => {}) // Install any additional dependencies defined in the package.json // This allows local plugins and custom tools to use external packages await BunProc.run(["install"], { cwd: dir }).catch(() => {}) } function rel(item: string, patterns: string[]) { for (const pattern of patterns) { const index = item.indexOf(pattern) if (index === -1) continue return item.slice(index + pattern.length) } } function trim(file: string) { const ext = path.extname(file) return ext.length ? file.slice(0, -ext.length) : file } const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md") async function loadCommand(dir: string) { const result: Record<string, Command> = {} for await (const item of COMMAND_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse command ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load command", { command: item, err }) return undefined }) if (!md) continue const patterns = [ "/.openscience/command/", "/.openscience/commands/", "/.synsc/command/", "/.synsc/commands/", "/command/", "/commands/", ] const file = rel(item, patterns) ?? path.basename(item) const name = trim(file) const config = { name, ...md.data, template: md.content.trim(), } const parsed = Command.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) } return result } const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md") async function loadAgent(dir: string) { const result: Record<string, Agent> = {} for await (const item of AGENT_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse agent ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load agent", { agent: item, err }) return undefined }) if (!md) continue const patterns = [ "/.openscience/agent/", "/.openscience/agents/", "/.synsc/agent/", "/.synsc/agents/", "/agent/", "/agents/", ] const file = rel(item, patterns) ?? path.basename(item) const agentName = trim(file) const config = { name: agentName, ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = parsed.data continue } throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error }) } return result } const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md") async function loadMode(dir: string) { const result: Record<string, Agent> = {} for await (const item of MODE_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { const md = await ConfigMarkdown.parse(item).catch(async (err) => { const message = ConfigMarkdown.FrontmatterError.isInstance(err) ? err.data.message : `Failed to parse mode ${item}` const { Session } = await import("@/session") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) log.error("failed to load mode", { mode: item, err }) return undefined }) if (!md) continue const config = { name: path.basename(item, ".md"), ...md.data, prompt: md.content.trim(), } const parsed = Agent.safeParse(config) if (parsed.success) { result[config.name] = { ...parsed.data, mode: "primary" as const, } continue } } return result } const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}") async function loadPlugin(dir: string) { const plugins: string[] = [] for await (const item of PLUGIN_GLOB.scan({ absolute: true, followSymlinks: true, dot: true, cwd: dir, })) { plugins.push(pathToFileURL(item).href) } return plugins } /** * Extracts a canonical plugin name from a plugin specifier. * - For file:// URLs: extracts filename without extension * - For npm packages: extracts package name without version * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" * getPluginName("oh-my-openscience@2.4.3") // "oh-my-openscience" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { if (plugin.startsWith("file://")) { return path.parse(new URL(plugin).pathname).name } const lastAt = plugin.lastIndexOf("@") if (lastAt > 0) { return plugin.substring(0, lastAt) } return plugin } /** * Deduplicates plugins by name, with later entries (higher priority) winning. * Priority order (highest to lowest): * 1. Local plugin/ directory * 2. Local openscience.json * 3. Global plugin/ directory * 4. Global openscience.json * * Since plugins are added in low-to-high priority order, * we reverse, deduplicate (keeping first occurrence), then restore order. */ export function deduplicatePlugins(plugins: string[]): string[] { // seenNames: canonical plugin names for duplicate detection // e.g., "oh-my-openscience", "@scope/pkg" const seenNames = new Set<string>() // uniqueSpecifiers: full plugin specifiers to return // e.g., "oh-my-openscience@2.4.3", "file:///path/to/plugin.js" const uniqueSpecifiers: string[] = [] for (const specifier of plugins.toReversed()) { const name = getPluginName(specifier) if (!seenNames.has(name)) { seenNames.add(name) uniqueSpecifiers.push(specifier) } } return uniqueSpecifiers.toReversed() } export const McpLocal = z .object({ type: z.literal("local").describe("Type of MCP server connection"), command: z.string().array().describe("Command and arguments to run the MCP server"), environment: z .record(z.string(), z.string()) .optional() .describe("Environment variables to set when running the MCP server"), enabled: z.boolean().optional().describe("Enable or disable the MCP server on startup"), timeout: z .number() .int() .positive() .optional() .describe("Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified."), }) .strict() .meta({