1552 lines · 58.0 KB · TypeScript
RawDownload
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.