UNPKG

open-memex

Version:

Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.

1,174 lines • 51.2 kB
// `open-memex init` — one-command project setup (§17 adoption path).
// Pure file operation: no DB, no network. Safe to run in any directory.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { pathToFileURL, fileURLToPath } from "node:url";
import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
import { projectRoot } from "./paths.ts";
import { markFirstRunDone, clearFirstRunMarker } from "./first-run.ts";

const MARKER = "<!-- open-memex -->";

/** npm dist-tag carrying the 0.3.x preview line. */
const ALPHA_TAG = "open-memex@alpha";

/**
 * F30: pre-rename package name. The my-o-memory → open-memex rename left
 * editor configs behind that still load the OLD plugin/server — which writes
 * to the OLD data dir, silently splitting the user's memories in two.
 * init, uninstall and doctor treat these leftovers as stale and remove (or
 * report) them wherever they touch a config.
 */
export const LEGACY_PACKAGE_NAME = "my-o-memory";

export interface McpCommand {
  command: string;
  args: string[];
  /** false when no durable bin exists (e.g. one-shot npx) and we fell back to npx. */
  durable: boolean;
}

/**
 * Resolve the MCP server command to write into client configs.
 *
 * A one-shot `npx open-memex@alpha init` runs from npm's ephemeral `_npx` cache,
 * so `open-memex` resolving on PATH *inside that process* does not mean it will be
 * there tomorrow. Only a bin found on PATH outside `_npx` cache dirs counts as
 * durable; otherwise fall back to an npx-based command (slower startup, zero install).
 */
export function resolveMcpCommand(): McpCommand {
  const pathEnv = process.env.PATH ?? "";
  const dirs = pathEnv.split(path.delimiter).filter((d) => d && !d.includes("_npx"));
  const names = process.platform === "win32" ? ["open-memex.cmd", "open-memex"] : ["open-memex"];
  const durable = dirs.some((d) =>
    names.some((n) => {
      try {
        return fs.existsSync(path.join(d, n));
      } catch {
        return false;
      }
    }),
  );
  if (durable) return { command: "open-memex", args: ["mcp"], durable: true };
  return { command: "npx", args: ["-y", ALPHA_TAG, "mcp"], durable: false };
}

const INSTRUCTIONS = `${MARKER}
# OpenMemex memory

> Applies only when the \`open-memex\` MCP server is available in this session
> (the \`memory_*\` tools exist). Otherwise ignore this section.

You have a local memory MCP server (\`open-memex\`). Its tools are
\`memory_add\`, \`memory_search\`, \`memory_list\`, \`memory_supersede\`,
\`memory_forget\`, \`memory_status\`, \`memory_submit\`, \`memory_propose\`,
\`memory_promote\`, \`memory_resolve\`, \`memory_pr_status\` — always call them by
these full names.

- The server tells you when project outbox drafts are waiting for review — in
  tool results. When it does (or when the user asks), call \`memory_status\`
  to see them; don't call it blindly at session start. When drafts are
  waiting, summarize them (one line each) and ask the user which ones to
  sync into the repo; sync NOTHING the user did not name. If you already
  asked about these drafts this session, don't ask again. When the server
  reports none waiting, do nothing.
- BE PROACTIVE about facts the user states directly: when the user shares a
  decision, preference, project convention, or fix-and-cause worth remembering
  across sessions, call \`memory_add\` without being asked. Keep each memory to
  one self-contained statement, and add a brief "(noted in memory)" so the
  user sees it worked.
- For conclusions YOU infer (the user never stated them): when you finish a
  task the user would describe in one sentence, consider distilling the
  session — if there is something worth keeping,
  propose 1–3 short memories capturing the useful conclusion (what was learned
  or decided, how an issue was resolved, what to avoid, where the authoritative
  doc lives — not the raw transcript), each with its proposed scope. Save
  NOTHING the user did not approve; on approval call \`memory_add\` with source
  "inference" at the approved scope. If the knowledge already lives in project
  docs, save it as type "reference" pointing at the doc instead of copying it.
  Long-form notes are fine ONLY when the user explicitly asks to save one.
- Before asking the user about past decisions, conventions, or preferences
  they may have told you before, call \`memory_search\` first — try a few
  keyword variants (including the user's own language) when the first search
  comes up empty.
- Memories default to this project's scope; use the \`personal\` scope for facts
  about the user that hold across all projects. When a saved fact becomes
  outdated, call \`memory_supersede\` (find the old memory's id with
  \`memory_search\` first) instead of adding a duplicate.

## Syncing project memories for review (D26)

Project memories you save land in a local outbox first — they are NOT in git
yet. Syncing them into the repo for review is an explicit, user-approved step:

- When the server reports drafts waiting for review, call \`memory_status\` to
  see them. Summarize the drafts (one line each) and ask the user which ones
  to sync. Sync NOTHING the user did not name.
- When the user says "sync memory" (or "同步记忆"), run the sync
  flow above: call \`memory_status\`, summarize the outbox drafts, and ask which
  ones to sync. ALWAYS use the \`memory_status\` tool for this — never browse
  the memory data directory directly.
- When the user approves, call \`memory_submit\` with the approved ids. It
  copies the drafts into the current project's \`.ai/open-memex/\` directory as
  \`proposed\` and commits locally on the CURRENT branch. It NEVER creates a
  branch on its own.
- After the submit, ask ONE follow-up: "want me to create a branch + push +
  open the PR, or will you handle it yourself?" A "yes, you do it" answer
  covers the whole chain — branch creation, push, PR creation — do NOT re-ask
  at each step. If the user says they will do it themselves, hand them the
  printed push/PR commands and do nothing. NEVER create branches, push, or open
  PRs without their explicit approval.
- If the user wants the memories reviewed on a separate branch, create the
  branch first (the commit comes along), then push and open the PR. The PR base
  defaults to the branch submit ran on; \`--base\` overrides it (e.g. \`main\` for
  branch-independent knowledge).
- If anything conflicts (same id with different content, push rejected), STOP
  and let the user judge — never overwrite.
- After the PR merges, call \`memory_pr_status\` (with \`apply\` when the user
  approves) to map the PR's review state back onto each memory — merged means
  \`published\`, an approval means \`approved\` (credited to the reviewer).
- \`personal\` scope memories NEVER leave the machine.
`;

function writeMcpJson(root: string, client: string, force: boolean): string | null {
  if (client === "opencode") return writeOpencodeMcpJson(root, force);
  if (client === "visualstudio") return writeVisualStudioMcpJson(root, force);
  const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
  const file = path.join(dir, "mcp.json");
  const sectionKey = client === "cursor" ? "mcpServers" : "servers";
  const { entry, durable } = stdioServerEntry(client);
  const written = writeServerEntryFile(file, sectionKey, entry, force, true);
  if (written) warnNonDurable(durable);
  return written;
}

/**
 * D45: user-level MCP config path — `init --global` writes here so one init
 * covers all projects. `platform` and `home` are parameters (default: current)
 * so tests can cover all OS layouts without mocking.
 */
export function userMcpConfigPath(
  client: "vscode" | "cursor",
  platform: NodeJS.Platform = process.platform,
  home: string = os.homedir(),
): string {
  if (client === "cursor") return path.join(home, ".cursor", "mcp.json");
  if (platform === "win32") {
    const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
    return path.join(appData, "Code", "User", "mcp.json");
  }
  if (platform === "darwin") {
    return path.join(home, "Library", "Application Support", "Code", "User", "mcp.json");
  }
  const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
  return path.join(xdg, "Code", "User", "mcp.json");
}

/** The open-memex server entry, same shape init writes at project level. */
function stdioServerEntry(client: string): { entry: Record<string, unknown>; durable: boolean } {
  // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
  const mc = resolveMcpCommand();
  const entry: Record<string, unknown> =
    client === "cursor"
      ? { command: mc.command, args: mc.args }
      : {
          type: "stdio",
          command: mc.command,
          args: mc.args,
          // VS Code substitutes ${workspaceFolder} per window, so the server
          // starts with the open project as cwd and scope resolution just works.
          cwd: "${workspaceFolder}",
        };
  return { entry, durable: mc.durable };
}

/** Warn when init had to fall back to an npx-based server command. */
function warnNonDurable(durable: boolean): void {
  if (durable) return;
  console.log(`  ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
  console.log(`    For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
}

/**
 * Pure merge of one server entry into a parsed config doc.
 * Returns "added" when the entry was written, "kept" when an entry already
 * existed and force was not set. Mutates `doc`.
 */
export function mergeServerEntry(
  doc: Record<string, unknown>,
  sectionKey: string,
  entry: Record<string, unknown>,
  force: boolean,
): "added" | "kept" {
  const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
  if (section["open-memex"] && !force) return "kept";
  section["open-memex"] = entry;
  return "added";
}

/**
 * D46-followup: when a config file isn't valid JSON we leave it untouched —
 * but "fix it manually" alone isn't helpful. Print the exact snippet the
 * user needs to add by hand (same idea as the opencode --global hint).
 */
export function printManualEntryHint(sectionKey: string, entry: Record<string, unknown>): void {
  console.error(`    Add this under "${sectionKey}":`);
  const pretty = JSON.stringify({ "open-memex": entry }, null, 2).replace(/\n/g, "\n    ");
  console.error(`    ${pretty}`);
}

/**
 * Parse a JSON config file's raw text for merging. An empty or
 * whitespace-only file counts as `{}` — there is nothing to preserve, so
 * writers can safely populate it (an empty mcp.json is a normal first-run
 * state, e.g. created by the editor, not a corrupt file). Returns null when
 * the content is genuinely unparseable (e.g. JSONC comments) or not an
 * object, in which case the caller leaves the file untouched and prints the
 * manual step.
 */
export function parseJsonConfig(raw: string): Record<string, unknown> | null {
  if (raw.trim() === "") return {};
  let doc: unknown;
  try {
    doc = JSON.parse(raw);
  } catch {
    return null;
  }
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
  return doc as Record<string, unknown>;
}

/**
 * Read (or create) a JSON config file, merge the open-memex server entry, write
 * it back. Existing files are merged, never clobbered; invalid JSON is left
 * untouched. `mkdir` controls whether parent dirs are created (user-level
 * configs) or expected to exist via the project root.
 */
function writeServerEntryFile(
  file: string,
  sectionKey: string,
  entry: Record<string, unknown>,
  force: boolean,
  mkdir: boolean,
): string | null {
  let doc: Record<string, unknown> = {};
  if (fs.existsSync(file)) {
    const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
    if (parsed === null) {
      console.error(`  ! ${file} is not valid JSON — left untouched, fix it manually`);
      printManualEntryHint(sectionKey, entry);
      return null;
    }
    doc = parsed;
  }
  const merged = mergeServerEntry(doc, sectionKey, entry, force);
  // F30: drop a stale my-o-memory server entry — the rename left configs
  // loading the OLD server, which writes to the OLD data dir.
  const staleRemoved = removeServerEntry(doc, sectionKey, LEGACY_PACKAGE_NAME) === "removed";
  if (merged === "kept" && !staleRemoved) {
    console.log(`  = ${file} already configures open-memex — left as is (use --force to overwrite)`);
    return file;
  }
  if (mkdir) fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
  if (staleRemoved)
    console.log(`  - ${file} (stale my-o-memory server entry removed — package renamed to open-memex)`);
  console.log(`  + ${file}`);
  return file;
}

/** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
function writeGlobalMcpJson(client: "vscode" | "cursor", force: boolean): string | null {
  const file = userMcpConfigPath(client);
  const sectionKey = client === "cursor" ? "mcpServers" : "servers";
  const { entry, durable } = stdioServerEntry(client);
  const written = writeServerEntryFile(file, sectionKey, entry, force, true);
  if (written) {
    warnNonDurable(durable);
    const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
    console.log(`  i user-level config — the open-memex MCP server now starts in every project.`);
    console.log(`    (a per-project ${projFile} still wins if a project defines its own)`);
  }
  return written;
}

/** Where this installed copy's opencode native plugin entry point lives. */
function opencodePluginUrl(): string {
  // init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
  const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
  return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
}

/**
 * D60: the same plugin directory, in the form OpenCode 2 wants. v2 rejects
 * file entries in `plugins` ("configured plugin path must be a directory")
 * and loads `<dir>/index.ts` instead.
 */
function opencodePluginDir(): string {
  const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
  return path.join(pkgRoot, "src");
}

/**
 * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
 * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
 * loads open-memex in every project with no per-project init.
 */
export function opencodeGlobalConfigPath(home: string = os.homedir()): string {
  const dir = opencodeConfigDir(home, process.env.XDG_CONFIG_HOME);
  // D64: init writes into whichever of .json/.jsonc already exists
  // (writeOpencodeGlobalPlugin), so readers must resolve the same file.
  // Hardcoding .json made uninstall report "nothing to remove" on
  // .jsonc-only setups and left the plugin entry behind.
  for (const name of ["opencode.jsonc", "opencode.json"]) {
    const candidate = path.join(dir, name);
    if (fs.existsSync(candidate)) return candidate;
  }
  return path.join(dir, "opencode.json");
}

/**
 * Pure merge of the open-memex plugin URL into a parsed opencode config doc.
 * Creates the `plugin` array when missing; never duplicates the URL.
 * Returns "added" when the doc changed, "kept" when already present.
 */
export function mergePluginEntry(
  doc: Record<string, unknown>,
  url: string,
  force: boolean,
): "added" | "kept" {
  let plugins = doc["plugin"];
  if (!Array.isArray(plugins)) {
    plugins = [] as unknown[];
    doc["plugin"] = plugins;
  }
  const list = plugins as unknown[];
  if (list.includes(url) && !force) return "kept";
  if (!list.includes(url)) list.push(url);
  return "added";
}

/** The package path an OpenCode 2 `plugins` entry refers to, if any. */
function v2EntryPackage(entry: unknown): string | null {
  if (typeof entry === "string") return entry;
  if (entry !== null && typeof entry === "object" && "package" in entry) {
    const p = (entry as { package?: unknown }).package;
    return typeof p === "string" ? p : null;
  }
  return null;
}

/**
 * D60: pure merge of the open-memex directory into the `plugins` array
 * (the OpenCode 2 spelling). An existing open-memex entry (any form)
 * counts as present; with force, stale open-memex entries are replaced
 * by `dir`. Same "added"/"kept" contract as mergePluginEntry.
 */
export function mergeV2PluginEntry(
  doc: Record<string, unknown>,
  dir: string,
  force: boolean,
): "added" | "kept" {
  let plugins = doc["plugins"];
  if (!Array.isArray(plugins)) {
    plugins = [] as unknown[];
    doc["plugins"] = plugins;
  }
  const list = plugins as unknown[];
  const ours = (e: unknown) => (v2EntryPackage(e) ?? "").includes("open-memex");
  if (!force && list.some(ours)) return "kept";
  for (let i = list.length - 1; i >= 0; i--) {
    const p = v2EntryPackage(list[i]);
    if (p === dir) {
      if (!force) return "kept";
      list.splice(i, 1);
    } else if (force && ours(list[i])) {
      list.splice(i, 1);
    }
  }
  list.push(dir);
  return "added";
}

/**
 * Pure removal of open-memex entries from the `plugins` array (OpenCode 2
 * spelling — strings and `{package}` objects). Drops the key when empty.
 */
export function removeV2PluginEntry(
  doc: Record<string, unknown>,
  match: string,
): "removed" | "absent" {
  const plugins = doc["plugins"];
  if (!Array.isArray(plugins)) return "absent";
  const kept = (plugins as unknown[]).filter((p) => !(v2EntryPackage(p) ?? "").includes(match));
  if (kept.length === plugins.length) return "absent";
  if (kept.length === 0) delete doc["plugins"];
  else doc["plugins"] = kept;
  return "removed";
}

/**
 * D46: wire the native plugin at the opencode user level — the one-time
 * global setup. Merges into the existing config when it parses as JSON;
 * a file with comments (JSONC) is left untouched with a manual hint instead
 * of being clobbered.
 */
function writeOpencodeGlobalPlugin(force: boolean): string | null {
  const dir = path.dirname(opencodeGlobalConfigPath());
  const file =
    ["opencode.jsonc", "opencode.json"]
      .map((f) => path.join(dir, f))
      .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
  const url = opencodePluginUrl();
  const pluginDir = opencodePluginDir();
  let doc: Record<string, unknown> = {};
  if (fs.existsSync(file)) {
    try {
      doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
    } catch {
      console.log(`  ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
      console.log(`    To enable open-memex everywhere, add "plugin": ["${url}"] (opencode 1)`);
      console.log(`    and "plugins": ["${pluginDir}"] (opencode 2) to it.`);
      console.log(`    Also remove any "${LEGACY_PACKAGE_NAME}" entries from "plugin" (stale — package renamed).`);
      return null;
    }
  }
  // F30: the my-o-memory → open-memex rename left the OLD plugin loading
  // alongside the new one — it writes to the OLD data dir, silently splitting
  // memories in two. Drop it wherever init touches the plugin list.
  const staleRemoved = removePluginEntry(doc, LEGACY_PACKAGE_NAME) === "removed";
  removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
  const merged = mergePluginEntry(doc, url, force);
  // D60: write both key generations — opencode 1 reads `plugin`, opencode 2
  // reads `plugins`; each host ignores the other's key.
  const mergedV2 = mergeV2PluginEntry(doc, pluginDir, force);
  if (merged === "kept" && mergedV2 === "kept" && !staleRemoved) {
    console.log(`  = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
    return file;
  }
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
  if (staleRemoved)
    console.log(`  - ${file} (stale my-o-memory plugin entry removed — package renamed to open-memex)`);
  if (merged === "added" || mergedV2 === "added")
    console.log(`  + ${file} (native plugin — works in every project, no per-project init needed)`);
  return file;
}

/** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
function writeOpencodeMcpJson(root: string, force: boolean): string | null {
  const file = path.join(root, "opencode.jsonc");
  let doc: Record<string, unknown> = {};
  if (fs.existsSync(file)) {
    const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
    if (parsed === null) {
      console.error(`  ! ${file} is not valid JSON — left untouched, fix it manually`);
      const mc = resolveMcpCommand();
      printManualEntryHint("mcp", {
        type: "local",
        command: [mc.command, ...mc.args],
        enabled: true,
      });
      return null;
    }
    doc = parsed;
  }
  const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
  const staleRemoved = removeServerEntry(doc, "mcp", LEGACY_PACKAGE_NAME) === "removed";
  if (section["open-memex"] && !force && !staleRemoved) {
    console.log(`  = ${file} already configures open-memex — left as is (use --force to overwrite)`);
    return file;
  }
  // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
  const mc = resolveMcpCommand();
  section["open-memex"] = {
    type: "local",
    command: [mc.command, ...mc.args],
    enabled: true,
  };
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
  if (staleRemoved)
    console.log(`  - ${file} (stale my-o-memory MCP entry removed — package renamed to open-memex)`);
  console.log(`  + ${file}`);
  if (!mc.durable) {
    console.log(`  ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
    console.log(`    For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
  }
  return file;
}

/** Visual Studio (Windows-only, 2022 17.14+ / 2026): solution-level `.mcp.json`
 * with the `"servers"` section, per Microsoft Learn. Source-controllable.
 * (VS also auto-discovers `.vscode/mcp.json` and `.cursor/mcp.json`.) */
function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
  const file = path.join(root, ".mcp.json");
  let doc: Record<string, unknown> = {};
  if (fs.existsSync(file)) {
    const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
    if (parsed === null) {
      console.error(`  ! ${file} is not valid JSON — left untouched, fix it manually`);
      const mc = resolveMcpCommand();
      printManualEntryHint("servers", {
        type: "stdio",
        command: mc.command,
        args: mc.args,
      });
      return null;
    }
    doc = parsed;
  }
  const section = ((doc["servers"] ??= {}) as Record<string, unknown>);
  // F30: drop the pre-rename server entry — it points at the OLD data dir.
  const staleRemoved = removeServerEntry(doc, "servers", LEGACY_PACKAGE_NAME) === "removed";
  if (section["open-memex"] && !force && !staleRemoved) {
    console.log(`  = ${file} already configures open-memex — left as is (use --force to overwrite)`);
    return file;
  }
  // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
  const mc = resolveMcpCommand();
  section["open-memex"] = {
    type: "stdio",
    command: mc.command,
    args: mc.args,
  };
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
  if (staleRemoved)
    console.log(`  - ${file} (stale my-o-memory MCP entry removed — package renamed to open-memex)`);
  console.log(`  + ${file}`);
  if (!mc.durable) {
    console.log(`  ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
    console.log(`    For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
  }
  return file;
}

function writeInstructions(
  root: string,
  scope: "personal" | "project",
  client: string,
): string {
  const file =
    scope === "project"
      ? path.join(root, ".github", "copilot-instructions.md")
      : client === "visualstudio"
        ? path.join(os.homedir(), "copilot-instructions.md")
        : path.join(os.homedir(), ".copilot", "copilot-instructions.md");
  if (scope === "personal") {
    // A previous project-scoped init may have left the section behind — flag it
    // so the repo can go back to being open-memex-free for teammates.
    const proj = path.join(root, ".github", "copilot-instructions.md");
    if (fs.existsSync(proj) && fs.readFileSync(proj, "utf8").includes(MARKER)) {
      console.log(`  ! project-level instructions still present at ${proj}`);
      console.log(`    remove the open-memex section there to keep the repo clean.`);
    }
  }
  if (fs.existsSync(file)) {
    const cur = fs.readFileSync(file, "utf8");
    if (cur.includes(MARKER)) {
      console.log(`  = ${file} already has open-memex instructions — left as is`);
      return file;
    }
    fs.writeFileSync(file, cur.replace(/\s+$/, "") + "\n\n" + INSTRUCTIONS);
  } else {
    fs.mkdirSync(path.dirname(file), { recursive: true });
    fs.writeFileSync(file, INSTRUCTIONS);
  }
  console.log(`  + ${file}`);
  return file;
}

export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
export type InitClient = (typeof INIT_CLIENTS)[number];

/** Normalize --client values; accepts "visual-studio" as an alias. */
export function normalizeClient(c: string): string {
  const lower = c.toLowerCase();
  return lower === "visual-studio" ? "visualstudio" : lower;
}

/** Ask a yes/no question. Only called on a TTY when --yes was not passed. */
async function askBool(q: string, def: boolean): Promise<boolean> {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    const hint = def ? "Y/n" : "y/N";
    const ans = (await rl.question(`${q} [${hint}]: `)).trim().toLowerCase();
    if (!ans) return def;
    return ans === "y" || ans === "yes";
  } finally {
    rl.close();
  }
}

async function promptClient(): Promise<string | null> {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    const ans = (
      await rl.question(
        "Which editor? (1) VS Code  (2) Cursor  (3) opencode  (4) Visual Studio  (5) skip [1]: ",
      )
    ).trim();
    switch (ans) {
      case "":
      case "1":
        return "vscode";
      case "2":
        return "cursor";
      case "3":
        return "opencode";
      case "4":
        return "visualstudio";
      case "5":
        return null;
      default:
        console.log(`  ? unknown choice "${ans}" — editor setup skipped`);
        return null;
    }
  } finally {
    rl.close();
  }
}

/** D22: where the Copilot memory instructions live. Personal (default) is the
 * Copilot user-level location — all projects, never checked in. */
async function promptInstructionsScope(): Promise<"personal" | "project"> {
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    console.log("Where should the Copilot memory instructions live?");
    console.log("  1) personal — user-level, all projects, never checked into a repo");
    console.log("  2) project  — .github/copilot-instructions.md, shared with the repo");
    const ans = (await rl.question("Choice [1]: ")).trim();
    return ans === "2" ? "project" : "personal";
  } finally {
    rl.close();
  }
}

/** D45: ask whether the MCP server config should be project-level or user-level. */
async function promptConfigLevel(): Promise<boolean> {
  console.log("Where should the MCP server config live?");
  console.log("  1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
  console.log("  2) user-level — all projects, init once (VS Code / Cursor)");
  const rl = createInterface({ input: process.stdin, output: process.stdout });
  try {
    const ans = (await rl.question("Choice [1]: ")).trim();
    return ans === "2";
  } finally {
    rl.close();
  }
}

/** D46: injectable environment for editor detection (tests pass fakes). */
export interface DetectEnv {
  pathEnv?: string;
  home?: string;
  platform?: NodeJS.Platform;
  root?: string;
  /** Overrides $XDG_CONFIG_HOME for the opencode global config dir. */
  xdgConfigHome?: string;
}

// ---------------------------------------------------------------------------
// D54: Agent Skills. The bundled open-memex skill (skills/open-memex/SKILL.md)
// teaches skill-aware agents to use open-memex via the CLI. init copies it
// (not symlinks — Windows needs no Developer Mode) into each wired editor's
// user-level skills dir; uninstall removes only our directory.
// D55 exception: opencode with the native plugin (global mode) never gets the
// skill — the plugin already provides memory tools, and the duplicated CLI
// guidance only makes the agent chatty. See shouldInstallSkill.
// ---------------------------------------------------------------------------

/** Package root, two levels above this module (src/init.ts or dist/init.js). */
export function packageRoot(): string {
  return path.dirname(path.dirname(fileURLToPath(import.meta.url)));
}

/** Where the bundled skill lives inside the installed package. */
export function skillSourceDir(pkgRoot: string = packageRoot()): string {
  return path.join(pkgRoot, "skills", "open-memex");
}

/**
 * D54: user-level Agent Skills directory for the open-memex skill, per client.
 * null = the client has no skills concept (Visual Studio).
 */
export function skillTargetDir(
  client: InitClient,
  env: DetectEnv = {},
): string | null {
  const home = env.home ?? os.homedir();
  switch (client) {
    case "vscode":
      return path.join(home, ".copilot", "skills", "open-memex");
    case "cursor":
      return path.join(home, ".cursor", "skills", "open-memex");
    case "opencode":
      return path.join(
        opencodeConfigDir(home, env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME),
        "skills",
        "open-memex",
      );
    case "visualstudio":
      return null;
  }
}

/**
 * Install the bundled skill for one client. Skips when already present unless
 * force (a customized skill is never clobbered silently).
 */
export function writeSkill(
  client: InitClient,
  opts: { force?: boolean; pkgRoot?: string } & DetectEnv = {},
): "installed" | "skipped" | "unsupported" | "missing-source" {
  const target = skillTargetDir(client, opts);
  if (!target) return "unsupported";
  const source = skillSourceDir(opts.pkgRoot ?? packageRoot());
  if (!exists(path.join(source, "SKILL.md"))) return "missing-source";
  if (exists(target) && !opts.force) return "skipped";
  fs.rmSync(target, { recursive: true, force: true });
  fs.cpSync(source, target, { recursive: true });
  return "installed";
}

/** Remove the open-memex skill installed by init for one client. */
export function removeSkill(
  client: InitClient,
  env: DetectEnv = {},
): "removed" | "absent" {
  const target = skillTargetDir(client, env);
  if (!target || !exists(target)) return "absent";
  fs.rmSync(target, { recursive: true, force: true });
  return "removed";
}

/**
 * D55: whether init installs the Agent Skill for a client. opencode with the
 * native plugin (global mode) already gives the agent memory tools plus
 * keyword capture and context injection — the skill's CLI guidance is pure
 * duplication there and makes the agent chatty (two overlapping instruction
 * sets + a "MCP or CLI?" choice on every memory action). Everywhere else the
 * skill stays as the no-tools fallback.
 */
export function shouldInstallSkill(client: InitClient, global: boolean): boolean {
  if (client === "opencode" && global) return false;
  return true;
}

function exists(p: string): boolean {
  try {
    return fs.existsSync(p);
  } catch {
    return false;
  }
}

/** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
function binOnPath(name: string, pathEnv: string, platform: NodeJS.Platform): boolean {
  const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
  return pathEnv
    .split(path.delimiter)
    .filter((d) => d && !d.includes("_npx"))
    .some((d) => names.some((n) => exists(path.join(d, n))));
}

function opencodeConfigDir(home: string, xdg: string | undefined): string {
  return path.join(xdg || path.join(home, ".config"), "opencode");
}

/**
 * D46: detect which supported editors are installed. Used when `init` runs
 * without --client — one init wires every detected editor (user-level where
 * the editor supports it). Visual Studio is included only when the project
 * has a solution file, since VS config is solution-scoped by design.
 */
export function detectInstalledClients(env: DetectEnv = {}): InitClient[] {
  const platform = env.platform ?? process.platform;
  const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
  const home = env.home ?? os.homedir();
  const found: InitClient[] = [];
  // VS Code: bin on PATH, well-known install location, or an existing
  // user-level MCP config (a previous init counts as installed).
  const vscodeInstallPaths =
    platform === "win32"
      ? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
      : platform === "darwin"
        ? ["/Applications/Visual Studio Code.app"]
        : ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
  if (
    binOnPath("code", pathEnv, platform) ||
    vscodeInstallPaths.some(exists) ||
    exists(userMcpConfigPath("vscode", platform, home))
  ) {
    found.push("vscode");
  }
  // Cursor: bin on PATH or its user config dir exists.
  if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
    found.push("cursor");
  }
  // opencode: bin on PATH or its global config dir exists.
  const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
  if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
    found.push("opencode");
  }
  // Visual Studio: solution-scoped — only when the project has a .sln.
  let hasSln = false;
  try {
    hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
  } catch {
    hasSln = false;
  }
  if (hasSln) found.push("visualstudio");
  return found;
}

export async function initProject(opts: {
  client?: string;
  force: boolean;
  yes: boolean;
  instructions?: string;
  /** D45: write the MCP server entry to the editor's user-level config. */
  global?: boolean;
}): Promise<void> {
  const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
  const explicit = normalizeClient(opts.client ?? "");
  if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
    console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
    process.exit(1);
  }
  // D46: no --client → auto-detect installed editors and wire them all
  // (user-level where the editor supports it — init once). An explicit
  // --client keeps the old single-editor behavior.
  let clients: InitClient[];
  let autoGlobal = false;
  if (explicit) {
    clients = [explicit as InitClient];
  } else if (interactive) {
    const detected = detectInstalledClients();
    if (detected.length === 0) {
      console.log("  - no supported editors detected — editor setup skipped");
      clients = [];
    } else {
      console.log(`Detected editors: ${detected.join(", ")}`);
      const all = await askBool(
        "Wire up open-memex in all of them (one-time, user-level where supported)?",
        true,
      );
      if (all) {
        clients = detected;
        autoGlobal = true;
      } else {
        const one = await promptClient();
        clients = one ? [one as InitClient] : [];
      }
    }
  } else {
    clients = detectInstalledClients();
    autoGlobal = true;
    if (clients.length === 0) {
      console.log("  - no supported editors detected — editor setup skipped");
    } else {
      console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
    }
  }
  let global = !!opts.global || autoGlobal;
  // The project-vs-user-level choice only applies to an explicit single
  // client; auto mode is user-level by design (init once).
  if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
    global = await promptConfigLevel();
  }
  let scope: "personal" | "project" = "personal";
  if (opts.instructions) {
    if (opts.instructions !== "personal" && opts.instructions !== "project") {
      console.error(`unknown --instructions "${opts.instructions}" (personal|project)`);
      process.exit(1);
    }
    scope = opts.instructions;
  } else if (interactive) {
    scope = await promptInstructionsScope();
  }
  if (interactive) {
    // Install-time settings (D19). Non-default answers persist to the JSONC
    // config file; `open-memex config set` changes them later.
    const patch: Record<string, unknown> = {};
    // Keyword auto-capture is always on (Stone 2026-10-07): no init
    // question — there is no reason to turn it off. The
    // `keywordCaptureEnabled` config key is still honored if set manually.
    const injectOnFirstTurn = await askBool(
      "Inject relevant memories when a session starts?",
      DEFAULT_CONFIG.injectOnFirstTurn,
    );
    if (injectOnFirstTurn !== DEFAULT_CONFIG.injectOnFirstTurn)
      patch.injectOnFirstTurn = injectOnFirstTurn;
    // D61 capture-time aliases are always on (Stone 2026-10-07): the agent
    // may always attach its own alternate phrasings — no init question.
    // (Distinct from D80's automatic index-time synonym expansion, which
    // also always applies.) The `captureAliases` config key is still
    // honored if someone sets it manually; init just never asks anymore.
    if (Object.keys(patch).length > 0) {
      const file = saveConfig(patch);
      console.log(
        `  + settings saved to ${file} (change later with \`open-memex config set <key> <value>\`)`,
      );
    }
  }
  const root = projectRoot();
  console.log(`open-memex init — project root: ${root}`);
  for (const client of clients) {
    // D55: opencode's native plugin supersedes the skill — don't install it
    // there, and remove one a previous init left behind.
    const installSkill = shouldInstallSkill(client, global);
    if (client === "opencode" && !installSkill && removeSkill(client) === "removed") {
      console.log(`  - Agent Skill removed for opencode (superseded by the native plugin)`);
    }
    if (client === "vscode" || client === "cursor") {
      if (global) writeGlobalMcpJson(client, opts.force);
      else writeMcpJson(root, client, opts.force);
    } else if (client === "opencode") {
      // D46: the native plugin is the one-time global setup; without --global
      // (explicit single-client mode) keep the per-project plain-MCP config.
      if (global) writeOpencodeGlobalPlugin(opts.force);
      else writeMcpJson(root, client, opts.force);
    } else if (client === "visualstudio") {
      if (explicit && global) {
        console.log(`  - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
      } else {
        writeMcpJson(root, client, opts.force);
      }
    }
    if (!installSkill) continue;
    // D54: Agent Skills — user-level by design (init once), alongside the MCP wiring.
    const skill = writeSkill(client, { force: opts.force });
    if (skill === "installed") console.log(`  + Agent Skill installed (${skillTargetDir(client)})`);
    else if (skill === "skipped")
      console.log(`  - Agent Skill already present for ${client} (use --force to refresh)`);
  }
  if (clients.length === 0) {
    console.log("  - editor setup skipped");
  } else if (clients.some((c) => c !== "opencode")) {
    // copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
    // consumer already gets the guidance from the tool descriptions (D16).
    // D22: personal scope (default) writes to the Copilot user-level location
    // so the repo stays clean for teammates without open-memex.
    writeInstructions(root, scope, clients.find((c) => c !== "opencode")!);
  }
  console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
  // D51: close the init→first-use gap — one concrete next step so a new user
  // sees what "it works" looks like instead of stopping at "it's wired".
  console.log(`  Next step: \`open-memex add "standup is at 9:30"\` — then ask your agent what it remembers.`);
  // Privacy in one line, at the moment of maximum doubt (right after a tool
  // just wrote into the user's editors): local-only, nothing uploaded.
  console.log(`  Privacy: memories live only on this machine — personal ones never leave it, and nothing is uploaded anywhere.`);
  // D50: init completed — the bare-`open-memex` first-run offer won't ask again.
  markFirstRunDone("initialized");
}

// ---------------------------------------------------------------------------
// uninstall — the reverse of init (D48). Removes the editor wiring init wrote:
// the MCP server entry, the opencode native plugin line, and the Copilot
// instructions section. Memory data is never touched.
// ---------------------------------------------------------------------------

/**
 * Pure removal of a server entry from a parsed config doc.
 * Prunes the section when it becomes empty. Mutates `doc`.
 * `key` defaults to "open-memex"; F30 passes LEGACY_PACKAGE_NAME to sweep
 * the pre-rename leftovers.
 */
export function removeServerEntry(
  doc: Record<string, unknown>,
  sectionKey: string,
  key = "open-memex",
): "removed" | "absent" {
  const section = doc[sectionKey];
  if (!section || typeof section !== "object" || !(key in section)) return "absent";
  delete (section as Record<string, unknown>)[key];
  if (Object.keys(section as Record<string, unknown>).length === 0) delete doc[sectionKey];
  return "removed";
}

/**
 * Pure removal of open-memex plugin entries from a parsed opencode config doc.
 * Matches any plugin URL containing `match` (robust against the package moving
 * between installs). Prunes the `plugin` array when it becomes empty.
 * Mutates `doc`.
 */
export function removePluginEntry(doc: Record<string, unknown>, match: string): "removed" | "absent" {
  const plugins = doc["plugin"];
  if (!Array.isArray(plugins)) return "absent";
  const kept = (plugins as unknown[]).filter(
    (p) => !(typeof p === "string" && p.includes(match)),
  );
  if (kept.length === plugins.length) return "absent";
  if (kept.length === 0) delete doc["plugin"];
  else doc["plugin"] = kept;
  return "removed";
}

/**
 * Pure removal of the open-memex instructions section from a
 * copilot-instructions.md body. init always appends it last (MARKER..end),
 * so cutting from the marker to the end is exact; returns "" when nothing
 * but the section remains so the caller can delete the file.
 */
export function removeInstructionsSection(text: string): string {
  const idx = text.lastIndexOf(MARKER);
  if (idx < 0) return text;
  const rest = text.slice(0, idx).replace(/\s+$/, "");
  return rest ? rest + "\n" : "";
}

/**
 * Remove the server entry from one JSON config file. Existing files are
 * merged, never clobbered; invalid JSON is left untouched with the manual
 * step (same D47 treatment as the init write path).
 */
function removeServerEntryFile(file: string, sectionKey: string): "removed" | "absent" | null {
  if (!fs.existsSync(file)) return "absent";
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
  if (doc === null) {
    console.error(`  ! ${file} is not valid JSON — left untouched, fix it manually`);
    console.error(`    Delete the "open-memex" key under "${sectionKey}".`);
    return null;
  }
  const res = removeServerEntry(doc, sectionKey);
  // F30: also sweep the pre-rename leftovers — uninstalling open-memex while
  // the OLD my-o-memory entry still loads leaves a zombie server on the old data dir.
  const stale = removeServerEntry(doc, sectionKey, LEGACY_PACKAGE_NAME);
  if (res === "removed" || stale === "removed") {
    fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
    console.log(`  - ${file} (memory-plugin server entries removed)`);
    return "removed";
  }
  return "absent";
}

/** Remove the open-memex plugin URL from one opencode user-level config file. */
function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
  if (!fs.existsSync(file)) return "absent";
  const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
  if (doc === null) {
    console.error(`  ! ${file} is not valid JSON — left untouched, fix it manually`);
    console.error(`    Delete the open-memex URL from the "plugin" array and the open-memex path from "plugins".`);
    return null;
  }
  const res = removePluginEntry(doc, "open-memex");
  // D60: the v2 `plugins` entry goes too (strings and {package} objects).
  const resV2 = removeV2PluginEntry(doc, "open-memex");
  // F30: also sweep the pre-rename leftover — see removeServerEntryFile.
  const stale = removePluginEntry(doc, LEGACY_PACKAGE_NAME);
  removeV2PluginEntry(doc, LEGACY_PACKAGE_NAME);
  if (res === "removed" || resV2 === "removed" || stale === "removed") {
    fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
    console.log(`  - ${file} (memory-plugin entries removed)`);
    return "removed";
  }
  return "absent";
}

/** Remove the open-memex section from one copilot-instructions.md file. */
function removeInstructionsFile(file: string): "removed" | "absent" {
  if (!fs.existsSync(file)) return "absent";
  const cur = fs.readFileSync(file, "utf8");
  if (!cur.includes(MARKER)) return "absent";
  const next = removeInstructionsSection(cur);
  if (next === "") {
    fs.unlinkSync(file);
    console.log(`  - ${file} (deleted — it only held open-memex instructions)`);
  } else {
    fs.writeFileSync(file, next);
    console.log(`  - ${file} (open-memex instructions removed)`);
  }
  return "removed";
}

export async function uninstallProject(opts: {
  client?: string;
  /** D48: only the user-level config; without it, both levels are cleaned. */
  global?: boolean;
  yes: boolean;
}): Promise<void> {
  const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
  const explicit = normalizeClient(opts.client ?? "");
  if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
    console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
    process.exit(1);
  }
  // Mirror init's client resolution: explicit --client, else auto-detect.
  let clients: InitClient[];
  if (explicit) {
    clients = [explicit as InitClient];
  } else if (interactive) {
    const detected = detectInstalledClients();
    if (detected.length === 0) {
      console.log("  - no supported editors detected — nothing to remove");
      return;
    }
    console.log(`Detected editors: ${detected.join(", ")}`);
    const all = await askBool("Remove open-memex wiring from all of them?", true);
    if (all) {
      clients = detected;
    } else {
      const one = await promptClient();
      clients = one ? [one as InitClient] : [];
    }
  } else {
    clients = detectInstalledClients();
    if (clients.length > 0) {
      console.log(`Detected editors: ${clients.join(", ")} — removing from all (use --client to pick one)`);
    }
  }
  if (clients.length === 0) {
    console.log("  - nothing to remove");
    return;
  }
  // Without --global, clean both levels: init may have written either one,
  // and a leftover entry at the other level would be a surprise.
  const levels = opts.global ? ["user"] : ["project", "user"];
  const root = projectRoot();
  console.log(`open-memex uninstall — project root: ${root}`);
  let changed = 0;
  const bump = (r: "removed" | "absent" | null) => {
    if (r === "removed") changed++;
  };
  for (const client of clients) {
    if (client === "vscode" || client === "cursor") {
      const sectionKey = client === "cursor" ? "mcpServers" : "servers";
      for (const level of levels) {
        const file =
          level === "user"
            ? userMcpConfigPath(client)
            : path.join(root, client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json");
        bump(removeServerEntryFile(file, sectionKey));
      }
    } else if (client === "opencode") {
      for (const level of levels) {
        if (level === "user") bump(removeOpencodePluginFile(opencodeGlobalConfigPath()));
        else bump(removeServerEntryFile(path.join(root, "opencode.jsonc"), "mcp"));
      }
    } else if (client === "visualstudio") {
      // Solution-level only — --global is meaningless, same as init.
      bump(removeServerEntryFile(path.join(root, ".mcp.json"), "servers"));
    }
    // D54: remove the Agent Skill installed by init.
    bump(removeSkill(client));
  }
  // Copilot instructions: init may have written personal (default) or project.
  if (clients.some((c) => c !== "opencode")) {
    bump(removeInstructionsFile(path.join(os.homedir(), ".copilot", "copilot-instructions.md")));
    bump(removeInstructionsFile(path.join(os.homedir(), "copilot-instructions.md")));
    bump(removeInstructionsFile(path.join(root, ".github", "copilot-instructions.md")));
  }
  console.log(
    changed > 0
      ? `\nDone. Removed open-memex wiring from ${changed} file(s).`
      : "\nDone. Nothing to remove — no open-memex wiring found.",
  );
  console.log("Your memories are untouched (uninstall never deletes data).");
  // D50: unwiring is the reverse of init — drop the first-run marker so the
  // next bare `open-memex` offers to wire again.
  if (changed > 0) clearFirstRunMarker();
}