Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"figlet": "^1.9.4",
"open": "^10.1.0",
"ora": "^9.4.0",
"picocolors": "^1.1.1"
"picocolors": "^1.1.1",
"yaml": "^2.8.1"
},
"devDependencies": {
"@types/figlet": "^1.7.0",
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/__tests__/remove.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const mockSpinner = {
vi.mock("ora", () => ({ default: () => mockSpinner }));

import { registerRemoveCommand } from "../commands/remove.js";
import { readYamlConfig } from "../setup/mcp-writer.js";

let tempDir: string;
let originalCwd: string;
Expand Down Expand Up @@ -324,6 +325,48 @@ describe("remove command", () => {
});
});

test("removes Hermes MCP setup from YAML config", async () => {
const previousHermesHome = process.env.HERMES_HOME;
const hermesHome = join(tempDir, "hermes-home");
const configPath = join(hermesHome, "config.yaml");
const rulePath = join(hermesHome, "rules", "context7.md");
const mcpSkillPath = join(hermesHome, "skills", "context7-mcp", "SKILL.md");

process.env.HERMES_HOME = hermesHome;
try {
await mkdir(join(hermesHome, "rules"), { recursive: true });
await mkdir(join(hermesHome, "skills", "context7-mcp"), { recursive: true });
await writeFile(
configPath,
'display:\n language: zh\nmcp_servers:\n other:\n url: https://other.com\n context7:\n url: https://mcp.context7.com/mcp\n',
"utf-8"
);
await writeFile(rulePath, "hermes rule", "utf-8");
await writeFile(mcpSkillPath, "mcp skill", "utf-8");

await runCommand("remove", "--hermes", "--mcp");

const config = await readYamlConfig(configPath);
expect(config).toEqual({
display: { language: "zh" },
mcp_servers: { other: { url: "https://other.com" } },
});
expect(await exists(rulePath)).toBe(false);
expect(await exists(join(hermesHome, "skills", "context7-mcp"))).toBe(false);
expect(trackEvent).toHaveBeenCalledWith("remove", {
agents: ["hermes"],
scope: "global",
modes: ["mcp"],
});
} finally {
if (previousHermesHome === undefined) {
delete process.env.HERMES_HOME;
} else {
process.env.HERMES_HOME = previousHermesHome;
}
}
});

test("detects only agents with Context7 artifacts, not just agent folders", async () => {
const rulePath = join(tempDir, ".cursor", "rules", "context7.mdc");
const cliSkillPath = join(tempDir, ".cursor", "skills", "find-docs", "SKILL.md");
Expand Down
153 changes: 152 additions & 1 deletion packages/cli/src/__tests__/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
buildTomlServerBlock,
appendTomlServer,
removeTomlServer,
readYamlConfig,
writeYamlConfig,
appendYamlServer,
removeYamlServer,
resolveMcpPath,
isStdioContext7Entry,
patchStdioApiKey,
Expand Down Expand Up @@ -534,6 +538,89 @@ describe("TOML config", () => {
});
});

describe("YAML MCP config", () => {
let tempDir: string;

beforeEach(async () => {
tempDir = join(tmpdir(), `ctx7-yaml-test-${Date.now()}`);
await mkdir(tempDir, { recursive: true });
});

afterEach(async () => {
await rm(tempDir, { recursive: true, force: true });
});

test("readYamlConfig returns empty object for missing or empty config", async () => {
expect(await readYamlConfig(join(tempDir, "missing.yaml"))).toEqual({});
const path = join(tempDir, "config.yaml");
await writeFile(path, "\n", "utf-8");
expect(await readYamlConfig(path)).toEqual({});
});

test("appendYamlServer adds context7 under mcp_servers and preserves existing keys", async () => {
const path = join(tempDir, "config.yaml");
await writeFile(
path,
"model:\n provider: openrouter\nmcp_servers:\n other:\n url: https://other.com\n",
"utf-8"
);

const { alreadyExists } = await appendYamlServer(path, "context7", {
url: "https://mcp.context7.com/mcp",
headers: { CONTEXT7_API_KEY: "sk-test-123" },
});

expect(alreadyExists).toBe(false);
const config = await readYamlConfig(path);
expect(config.model).toEqual({ provider: "openrouter" });
const servers = config.mcp_servers as Record<string, unknown>;
expect(servers.other).toEqual({ url: "https://other.com" });
expect(servers.context7).toEqual({
url: "https://mcp.context7.com/mcp",
headers: { CONTEXT7_API_KEY: "sk-test-123" },
});
});

test("appendYamlServer overwrites existing context7 entry without duplicating", async () => {
const path = join(tempDir, "config.yaml");
await appendYamlServer(path, "context7", { url: "https://old.com" });
const { alreadyExists } = await appendYamlServer(path, "context7", {
url: "https://mcp.context7.com/mcp/oauth",
});

expect(alreadyExists).toBe(true);
const config = await readYamlConfig(path);
expect((config.mcp_servers as Record<string, unknown>).context7).toEqual({
url: "https://mcp.context7.com/mcp/oauth",
});
const content = await readFile(path, "utf-8");
expect(content.match(/context7:/g)?.length).toBe(1);
expect(content).not.toContain("https://old.com");
});

test("removeYamlServer removes only context7 and cleans empty mcp_servers", async () => {
const path = join(tempDir, "config.yaml");
await writeYamlConfig(path, {
display: { language: "zh" },
mcp_servers: {
other: { url: "https://other.com" },
context7: { url: "https://mcp.context7.com/mcp" },
},
});

expect(await removeYamlServer(path, "context7")).toEqual({ removed: true });
let config = await readYamlConfig(path);
expect(config).toEqual({
display: { language: "zh" },
mcp_servers: { other: { url: "https://other.com" } },
});

expect(await removeYamlServer(path, "other")).toEqual({ removed: true });
config = await readYamlConfig(path);
expect(config).toEqual({ display: { language: "zh" } });
});
});

describe("AGENTS.md append", () => {
let tempDir: string;

Expand Down Expand Up @@ -1020,6 +1107,53 @@ describe("agent config integration", () => {
});
});

describe("hermes", () => {
const agent = getAgent("hermes");

test("buildEntry with api-key writes Hermes HTTP MCP shape", () => {
expect(agent.mcp.buildEntry(apiKeyAuth, "http")).toEqual({
url: "https://mcp.context7.com/mcp",
headers: { CONTEXT7_API_KEY: "sk-test-123" },
timeout: 120,
connect_timeout: 60,
});
});

test("buildEntry with oauth writes Hermes OAuth URL without headers", () => {
expect(agent.mcp.buildEntry(oauthAuth, "http")).toEqual({
url: "https://mcp.context7.com/mcp/oauth",
timeout: 120,
connect_timeout: 60,
});
});

test("stdio entry uses env instead of exposing api key in args", () => {
const entry = agent.mcp.buildEntry(apiKeyAuth, "stdio");
expect(entry.command).toBe("node");
expect(entry.args[0]).toBe("-e");
expect(entry.args[1]).toContain("@upstash/context7-mcp");
expect(entry.env).toEqual({ CONTEXT7_API_KEY: "sk-test-123" });
expect(entry.timeout).toBe(120);
expect(entry.connect_timeout).toBe(60);
});

test("merges into Hermes YAML config under mcp_servers", async () => {
const path = join(tempDir, "config.yaml");
await writeYamlConfig(path, { display: { language: "zh" } });

await appendYamlServer(path, "context7", agent.mcp.buildEntry(apiKeyAuth, "http"));

const config = await readYamlConfig(path);
expect(config.display).toEqual({ language: "zh" });
expect((config.mcp_servers as Record<string, unknown>).context7).toEqual({
url: "https://mcp.context7.com/mcp",
headers: { CONTEXT7_API_KEY: "sk-test-123" },
timeout: 120,
connect_timeout: 60,
});
});
});

describe("all agents have consistent config", () => {
test("all agents are covered", () => {
expect(ALL_AGENT_NAMES).toEqual([
Expand All @@ -1029,6 +1163,7 @@ describe("agent config integration", () => {
"codex",
"antigravity",
"gemini",
"hermes",
]);
});

Expand Down Expand Up @@ -1103,11 +1238,27 @@ describe("agent config integration", () => {
});
});

test("hermes stdio entry uses env instead of --api-key args", () => {
const entry = getAgent("hermes").mcp.buildEntry(apiKeyAuth, "stdio");
expect(entry.command).toBe("node");
expect(entry.args[0]).toBe("-e");
expect(entry.args[1]).toContain("@upstash/context7-mcp");
expect(Object.keys(entry.env as Record<string, string>)).toEqual(["CONTEXT7_API_KEY"]);
expect(typeof (entry.env as Record<string, string>).CONTEXT7_API_KEY).toBe("string");
expect(entry.timeout).toBe(120);
expect(entry.connect_timeout).toBe(60);
});

test.each(ALL_AGENT_NAMES)("%s stdio entry omits --api-key for oauth mode", (name) => {
const entry = getAgent(name).mcp.buildEntry(oauthAuth, "stdio");
const args = (entry.args ?? entry.command) as string[];
expect(args).not.toContain("--api-key");
expect(args).toContain("@upstash/context7-mcp");
if (name === "hermes") {
// Hermes uses node -e inline script, check the script content
expect(args.some((arg) => typeof arg === "string" && arg.includes("@upstash/context7-mcp"))).toBe(true);
} else {
expect(args).toContain("@upstash/context7-mcp");
}
});

test("codex stdio entry serializes to TOML correctly", () => {
Expand Down
28 changes: 27 additions & 1 deletion packages/cli/src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import { ALL_AGENT_NAMES, SETUP_AGENT_NAMES, getAgent, type SetupAgent } from ".
import {
readJsonConfig,
readTomlServerExists,
readYamlConfig,
removeServerEntry,
writeJsonConfig,
resolveMcpPath,
removeTomlServer,
removeYamlServer,
} from "../setup/mcp-writer.js";
import { join } from "path";
import { access, readFile, rm, writeFile } from "fs/promises";
Expand All @@ -26,6 +28,7 @@ interface UninstallOptions {
codex?: boolean;
antigravity?: boolean;
gemini?: boolean;
hermes?: boolean;
project?: boolean;
yes?: boolean;
all?: boolean;
Expand Down Expand Up @@ -78,6 +81,7 @@ export function registerRemoveCommand(program: Command): void {
.option("--codex", "Remove from Codex")
.option("--antigravity", "Remove from Antigravity")
.option("--gemini", "Remove from Gemini CLI")
.option("--hermes", "Remove from Hermes Agent")
.option("--all", "Remove both MCP setup and CLI + Skills setup")
.option("--mcp", "Remove MCP setup")
.option("--cli", "Remove CLI + Skills setup")
Expand All @@ -96,6 +100,7 @@ function getSelectedAgents(options: UninstallOptions): SetupAgent[] {
if (options.codex) agents.push("codex");
if (options.antigravity) agents.push("antigravity");
if (options.gemini) agents.push("gemini");
if (options.hermes) agents.push("hermes");
return agents;
}

Expand Down Expand Up @@ -154,7 +159,7 @@ async function resolveAgents(options: UninstallOptions, scope: Scope): Promise<S

if (detected.length === 0) {
log.warn(
"No Context7 setup detected. Pass --claude, --cursor, --opencode, --codex, --antigravity, or --gemini."
"No Context7 setup detected. Pass --claude, --cursor, --opencode, --codex, --antigravity, --gemini, or --hermes."
);
return [];
}
Expand Down Expand Up @@ -204,6 +209,22 @@ async function hasMcpConfig(agentName: SetupAgent, scope: Scope): Promise<boolea
return readTomlServerExists(mcpPath, "context7");
}

if (mcpPath.endsWith(".yaml") || mcpPath.endsWith(".yml")) {
let existing: Record<string, unknown>;
try {
existing = await readYamlConfig(mcpPath);
} catch (err) {
log.warn(
`Skipped ${mcpPath}: could not parse (${err instanceof Error ? err.message : String(err)})`
);
return false;
}
const section = existing[agent.mcp.configKey];
return (
!!section && typeof section === "object" && !Array.isArray(section) && "context7" in section
);
}

let existing: Record<string, unknown>;
try {
existing = await readJsonConfig(mcpPath);
Expand Down Expand Up @@ -340,6 +361,11 @@ async function uninstallMcp(agentName: SetupAgent, scope: Scope): Promise<Cleanu
return { status: removed ? "removed" : "not found", path: mcpPath };
}

if (mcpPath.endsWith(".yaml") || mcpPath.endsWith(".yml")) {
const { removed } = await removeYamlServer(mcpPath, "context7", agent.mcp.configKey);
return { status: removed ? "removed" : "not found", path: mcpPath };
}

const existing = await readJsonConfig(mcpPath);
const { config, removed } = removeServerEntry(existing, agent.mcp.configKey, "context7");
if (removed) {
Expand Down
Loading