mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
Merge branch 'main' into fix/issue-1518-docker-local-path
This commit is contained in:
commit
cc8b2cfb45
11 changed files with 1535 additions and 35 deletions
|
|
@ -225,6 +225,7 @@ args = ["-y", "gitnexus@latest", "mcp"]
|
|||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
|
|
@ -261,6 +262,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
|||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
If `analyze` reports a worker parse timeout on a large or unusual repository, it keeps running and falls back safely. To give slow worker jobs more time, use `gitnexus analyze --worker-timeout 60` or set `GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS=60000`. For very large files, `GITNEXUS_WORKER_SUB_BATCH_MAX_BYTES` controls the worker job byte budget.
|
||||
|
||||
#### Embeddings node limit
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ Your AI agent gets these tools automatically:
|
|||
|
||||
```bash
|
||||
gitnexus setup # Configure MCP for your editors (one-time)
|
||||
gitnexus uninstall # Preview removal of GitNexus MCP/skills/hooks (add --force to apply)
|
||||
gitnexus analyze [path] # Index a repository (or update stale index)
|
||||
gitnexus analyze --repair-fts # Fast path: rebuild/verify only FTS indexes on existing index data
|
||||
gitnexus analyze --force # Full rebuild: re-parse + graph rebuild + FTS rebuild
|
||||
|
|
@ -196,6 +197,8 @@ gitnexus group query <name> <q> # Search execution flows across all repos in a
|
|||
gitnexus group status <name> # Check staleness of repos in a group
|
||||
```
|
||||
|
||||
> **`gitnexus uninstall`** reverses `gitnexus setup` — it removes the GitNexus MCP entries, hooks, and skill directories it added to each detected editor. Skill directories are identified **by bundled gitnexus skill name** (e.g. `gitnexus-cli/`), so if you customized files inside an installed skill directory, back them up first. It is a dry-run preview by default and prints the exact paths it would remove; pass `--force` to apply. Per-repo indexes (`gitnexus clean --all`) and the global npm package (`npm uninstall -g gitnexus`) are left for you to remove.
|
||||
|
||||
## Remote Embeddings
|
||||
|
||||
Set these env vars to use a remote OpenAI-compatible `/v1/embeddings` endpoint instead of the local model:
|
||||
|
|
|
|||
187
gitnexus/src/cli/editor-targets.ts
Normal file
187
gitnexus/src/cli/editor-targets.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/**
|
||||
* Editor targets — the single source of truth for *where* GitNexus writes its
|
||||
* per-editor configuration and *how* its entries are identified.
|
||||
*
|
||||
* `setup` (writes these) and `uninstall` (removes them) both consume this
|
||||
* module so the two stay structurally in lock-step: add or change a target
|
||||
* here and both sides follow. This is declarative metadata only — file
|
||||
* locations, JSON key paths, hook event names, command needles, and script
|
||||
* directories, plus the shared `detectIndentation` formatting helper. The
|
||||
* format-specific read/write logic (JSONC merge, TOML upsert, OpenCode's flat
|
||||
* command array, Gemini's hook schema) deliberately stays in setup.ts /
|
||||
* uninstall.ts.
|
||||
*
|
||||
* The `setup → uninstall` round-trip integration test verifies the two
|
||||
* implementations remain behaviourally symmetrical on top of this shared
|
||||
* structure.
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export type EditorId = 'cursor' | 'claude' | 'antigravity' | 'opencode' | 'codex';
|
||||
|
||||
/** An editor whose MCP config is a JSONC document (server keyed by name). */
|
||||
export interface McpJsoncTarget {
|
||||
id: EditorId;
|
||||
label: string;
|
||||
/** Absolute path to the editor's MCP config file. */
|
||||
file: string;
|
||||
/**
|
||||
* JSON path of the gitnexus server entry within that file. Typed as
|
||||
* `string[]` (all our keys are object keys) so it satisfies both setup's
|
||||
* `mergeJsoncFile(string[])` and uninstall's `removeJsoncKey(JSONPath)`
|
||||
* without either side needing a cast.
|
||||
*/
|
||||
keyPath: string[];
|
||||
}
|
||||
|
||||
/** Codex stores MCP config as a TOML table, not JSONC. */
|
||||
export interface CodexMcpTarget {
|
||||
id: 'codex';
|
||||
label: string;
|
||||
/** Absolute path to ~/.codex/config.toml. */
|
||||
configFile: string;
|
||||
/** The TOML table header (without brackets) setup writes / uninstall strips. */
|
||||
tomlSection: string;
|
||||
}
|
||||
|
||||
export interface SkillTarget {
|
||||
id: EditorId;
|
||||
label: string;
|
||||
/** Absolute path to the editor's skills directory. */
|
||||
dir: string;
|
||||
}
|
||||
|
||||
export interface HookTarget {
|
||||
id: EditorId;
|
||||
label: string;
|
||||
/** Absolute path to the editor's settings file (JSONC). */
|
||||
settingsFile: string;
|
||||
/** Hook event arrays that may hold a gitnexus entry. */
|
||||
events: string[];
|
||||
/** Substring identifying the gitnexus command within a hook entry. */
|
||||
needle: string;
|
||||
/** Absolute path to the bundled hook-script directory setup writes. */
|
||||
scriptDir: string;
|
||||
}
|
||||
|
||||
export interface EditorTargets {
|
||||
/** JSONC-format MCP entries: Cursor, Claude Code, Antigravity, OpenCode. */
|
||||
mcpJsonc: McpJsoncTarget[];
|
||||
/** Codex MCP (TOML). */
|
||||
codex: CodexMcpTarget;
|
||||
/** Skill install directories, one per editor that supports skills. */
|
||||
skills: SkillTarget[];
|
||||
/** Hook registrations + their bundled script directories. */
|
||||
hooks: HookTarget[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve all editor targets for the given home directory. Defaults to
|
||||
* `os.homedir()`; call sites pass it through so tests can point HOME at a temp
|
||||
* dir. Paths are computed at call time (not module load) so a test setting
|
||||
* `process.env.HOME` before invoking sees the right locations.
|
||||
*/
|
||||
export function getEditorTargets(home: string = os.homedir()): EditorTargets {
|
||||
const mcpJsonc: McpJsoncTarget[] = [
|
||||
{
|
||||
id: 'cursor',
|
||||
label: 'Cursor',
|
||||
file: path.join(home, '.cursor', 'mcp.json'),
|
||||
keyPath: ['mcpServers', 'gitnexus'],
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude Code',
|
||||
file: path.join(home, '.claude.json'),
|
||||
keyPath: ['mcpServers', 'gitnexus'],
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'),
|
||||
keyPath: ['mcpServers', 'gitnexus'],
|
||||
},
|
||||
{
|
||||
id: 'opencode',
|
||||
label: 'OpenCode',
|
||||
file: path.join(home, '.config', 'opencode', 'opencode.json'),
|
||||
// OpenCode nests servers under `mcp`, not `mcpServers`.
|
||||
keyPath: ['mcp', 'gitnexus'],
|
||||
},
|
||||
];
|
||||
|
||||
const codex: CodexMcpTarget = {
|
||||
id: 'codex',
|
||||
label: 'Codex',
|
||||
configFile: path.join(home, '.codex', 'config.toml'),
|
||||
tomlSection: 'mcp_servers.gitnexus',
|
||||
};
|
||||
|
||||
const skills: SkillTarget[] = [
|
||||
{ id: 'claude', label: 'Claude Code', dir: path.join(home, '.claude', 'skills') },
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
dir: path.join(home, '.gemini', 'antigravity', 'skills'),
|
||||
},
|
||||
{ id: 'cursor', label: 'Cursor', dir: path.join(home, '.cursor', 'skills') },
|
||||
{ id: 'opencode', label: 'OpenCode', dir: path.join(home, '.config', 'opencode', 'skills') },
|
||||
// Codex reads skills from ~/.agents/skills (not ~/.codex).
|
||||
{ id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') },
|
||||
];
|
||||
|
||||
const hooks: HookTarget[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
label: 'Claude Code',
|
||||
settingsFile: path.join(home, '.claude', 'settings.json'),
|
||||
events: ['PreToolUse', 'PostToolUse'],
|
||||
needle: 'gitnexus-hook',
|
||||
scriptDir: path.join(home, '.claude', 'hooks', 'gitnexus'),
|
||||
},
|
||||
{
|
||||
id: 'antigravity',
|
||||
label: 'Antigravity',
|
||||
settingsFile: path.join(home, '.gemini', 'settings.json'),
|
||||
events: ['AfterTool'],
|
||||
needle: 'gitnexus-antigravity-hook',
|
||||
scriptDir: path.join(home, '.gemini', 'config', 'hooks', 'gitnexus'),
|
||||
},
|
||||
];
|
||||
|
||||
return { mcpJsonc, codex, skills, hooks };
|
||||
}
|
||||
|
||||
/** Look up a single JSONC MCP target by editor id (throws if unknown). */
|
||||
export function mcpTarget(id: EditorId, home?: string): McpJsoncTarget {
|
||||
const t = getEditorTargets(home).mcpJsonc.find((m) => m.id === id);
|
||||
if (!t) throw new Error(`No JSONC MCP target for editor "${id}"`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Look up a single skill target by editor id (throws if unknown). */
|
||||
export function skillTarget(id: EditorId, home?: string): SkillTarget {
|
||||
const t = getEditorTargets(home).skills.find((s) => s.id === id);
|
||||
if (!t) throw new Error(`No skill target for editor "${id}"`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Look up a single hook target by editor id (throws if unknown). */
|
||||
export function hookTarget(id: EditorId, home?: string): HookTarget {
|
||||
const t = getEditorTargets(home).hooks.find((h) => h.id === id);
|
||||
if (!t) throw new Error(`No hook target for editor "${id}"`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect indentation style from file content so JSONC edits preserve the file's
|
||||
* existing formatting. Shared by setup (writes) and uninstall (removes).
|
||||
*/
|
||||
export function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
|
||||
const firstIndented = raw.match(/^( +|\t)/m);
|
||||
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
|
||||
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
|
||||
return { tabSize: firstIndented[1].length, insertSpaces: true };
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ const TITLE_KEYS = {
|
|||
const COMMAND_DESCRIPTION_KEYS = {
|
||||
'': 'help.description.root',
|
||||
setup: 'help.command.setup.description',
|
||||
uninstall: 'help.command.uninstall.description',
|
||||
analyze: 'help.command.analyze.description',
|
||||
index: 'help.command.index.description',
|
||||
serve: 'help.command.serve.description',
|
||||
|
|
@ -69,6 +70,7 @@ const OPTION_DESCRIPTION_KEYS = {
|
|||
'index|--allow-non-git': 'help.option.index.allowNonGit',
|
||||
'serve|-p, --port <port>': 'help.option.port',
|
||||
'serve|--host <host>': 'help.option.serve.host',
|
||||
'uninstall|-f, --force': 'help.option.uninstall.force',
|
||||
'clean|-f, --force': 'help.option.force.confirmation',
|
||||
'clean|--all': 'help.option.clean.all',
|
||||
'clean|--lbug-sidecars': 'help.option.clean.lbugSidecars',
|
||||
|
|
|
|||
|
|
@ -106,6 +106,8 @@ export const en = {
|
|||
'help.option.version': 'output the version number',
|
||||
'help.command.setup.description':
|
||||
'One-time setup: configure MCP for Cursor, Claude Code, OpenCode, Codex',
|
||||
'help.command.uninstall.description':
|
||||
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
|
||||
'help.command.analyze.description': 'Index a repository (full analysis)',
|
||||
'help.command.index.description':
|
||||
'Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)',
|
||||
|
|
@ -185,6 +187,7 @@ export const en = {
|
|||
'help.option.port': 'Port number',
|
||||
'help.option.serve.host': 'Bind address (default: 127.0.0.1, use 0.0.0.0 for remote access)',
|
||||
'help.option.force.confirmation': 'Skip confirmation prompt',
|
||||
'help.option.uninstall.force': 'Apply the changes (default is a dry-run preview)',
|
||||
'help.option.clean.all': 'Clean all indexed repos',
|
||||
'help.option.clean.lbugSidecars': 'Clean quarantined LadybugDB missing-shadow WAL sidecars',
|
||||
'help.option.wiki.force': 'Force full regeneration even if up to date',
|
||||
|
|
|
|||
|
|
@ -108,6 +108,8 @@ export const zhCN = {
|
|||
'help.option.help': '显示命令帮助',
|
||||
'help.option.version': '输出版本号',
|
||||
'help.command.setup.description': '一次性设置:为 Cursor、Claude Code、OpenCode、Codex 配置 MCP',
|
||||
'help.command.uninstall.description':
|
||||
'撤销 `setup`:从所有检测到的编辑器中移除 GitNexus 的 MCP 配置、技能和钩子',
|
||||
'help.command.analyze.description': '索引仓库(完整分析)',
|
||||
'help.command.index.description': '将现有 .gitnexus/ 文件夹注册到全局注册表(无需重新分析)',
|
||||
'help.command.serve.description': '启动供 Web UI 连接的本地 HTTP 服务器',
|
||||
|
|
@ -174,6 +176,7 @@ export const zhCN = {
|
|||
'help.option.port': '端口号',
|
||||
'help.option.serve.host': '绑定地址(默认:127.0.0.1;远程访问可用 0.0.0.0)',
|
||||
'help.option.force.confirmation': '跳过确认提示',
|
||||
'help.option.uninstall.force': '应用更改(默认仅为预演预览)',
|
||||
'help.option.clean.all': '清理所有已索引仓库',
|
||||
'help.option.clean.lbugSidecars': '清理已隔离的 LadybugDB missing-shadow WAL sidecar',
|
||||
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,14 @@ program
|
|||
)
|
||||
.action(createLazyAction(() => import('./setup.js'), 'setupCommand'));
|
||||
|
||||
program
|
||||
.command('uninstall')
|
||||
.description(
|
||||
'Reverse `setup`: remove GitNexus MCP entries, skills, and hooks from all detected editors',
|
||||
)
|
||||
.option('-f, --force', 'Apply the changes (default is a dry-run preview)')
|
||||
.action(createLazyAction(() => import('./uninstall.js'), 'uninstallCommand'));
|
||||
|
||||
program
|
||||
.command('analyze [path]')
|
||||
.description('Index a repository (full analysis)')
|
||||
|
|
|
|||
|
|
@ -15,6 +15,13 @@ import { promisify } from 'util';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { parseTree, modify, applyEdits, ParseError, parse as parseJsonc } from 'jsonc-parser';
|
||||
import { getGlobalDir } from '../storage/repo-manager.js';
|
||||
import {
|
||||
getEditorTargets,
|
||||
mcpTarget,
|
||||
skillTarget,
|
||||
hookTarget,
|
||||
detectIndentation,
|
||||
} from './editor-targets.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
|
@ -162,17 +169,6 @@ function getOpenCodeMcpEntry() {
|
|||
return { type: 'local', command: ['npx', '-y', MCP_PINNED_REF, 'mcp'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect indentation style from file content.
|
||||
* Returns formatting options matching the file's existing style.
|
||||
*/
|
||||
function detectIndentation(raw: string): { tabSize: number; insertSpaces: boolean } {
|
||||
const firstIndented = raw.match(/^( +|\t)/m);
|
||||
if (!firstIndented) return { tabSize: 2, insertSpaces: true };
|
||||
if (firstIndented[1] === '\t') return { tabSize: 1, insertSpaces: false };
|
||||
return { tabSize: firstIndented[1].length, insertSpaces: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a key/value pair into a JSONC config file, preserving comments and formatting.
|
||||
* If the file is genuinely corrupt (not valid JSONC), leaves it untouched.
|
||||
|
|
@ -233,9 +229,9 @@ async function setupCursor(result: SetupResult): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const mcpPath = path.join(cursorDir, 'mcp.json');
|
||||
const { file: mcpPath, keyPath } = mcpTarget('cursor');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
||||
if (ok) {
|
||||
result.configured.push('Cursor');
|
||||
} else {
|
||||
|
|
@ -254,9 +250,9 @@ async function setupClaudeCode(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
// Claude Code stores MCP config in ~/.claude.json
|
||||
const mcpPath = path.join(os.homedir(), '.claude.json');
|
||||
const { file: mcpPath, keyPath } = mcpTarget('claude');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
||||
if (ok) {
|
||||
result.configured.push('Claude Code');
|
||||
} else {
|
||||
|
|
@ -276,7 +272,7 @@ async function installClaudeCodeSkills(result: SetupResult): Promise<void> {
|
|||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
if (!(await dirExists(claudeDir))) return;
|
||||
|
||||
const skillsDir = path.join(claudeDir, 'skills');
|
||||
const skillsDir = skillTarget('claude').dir;
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
@ -422,13 +418,14 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
const claudeDir = path.join(os.homedir(), '.claude');
|
||||
if (!(await dirExists(claudeDir))) return;
|
||||
|
||||
const settingsPath = path.join(claudeDir, 'settings.json');
|
||||
const claudeHook = hookTarget('claude');
|
||||
const settingsPath = claudeHook.settingsFile;
|
||||
|
||||
// Source hooks bundled within the gitnexus package (hooks/claude/)
|
||||
const pluginHooksPath = path.join(__dirname, '..', '..', 'hooks', 'claude');
|
||||
|
||||
// Copy unified hook script to ~/.claude/hooks/gitnexus/
|
||||
const destHooksDir = path.join(claudeDir, 'hooks', 'gitnexus');
|
||||
const destHooksDir = claudeHook.scriptDir;
|
||||
|
||||
try {
|
||||
await fs.mkdir(destHooksDir, { recursive: true });
|
||||
|
|
@ -494,7 +491,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
// NOTE: SessionStart hooks are broken on Windows (Claude Code bug #23576).
|
||||
// Session context is delivered via CLAUDE.md / skills instead.
|
||||
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse')) {
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'PreToolUse', claudeHook.needle)) {
|
||||
hookEntries.push({
|
||||
eventName: 'PreToolUse',
|
||||
value: {
|
||||
|
|
@ -510,7 +507,7 @@ async function installClaudeCodeHooks(result: SetupResult): Promise<void> {
|
|||
},
|
||||
});
|
||||
}
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse')) {
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'PostToolUse', claudeHook.needle)) {
|
||||
hookEntries.push({
|
||||
eventName: 'PostToolUse',
|
||||
value: {
|
||||
|
|
@ -566,9 +563,9 @@ async function setupAntigravity(result: SetupResult): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const mcpPath = path.join(antigravityDir, 'mcp_config.json');
|
||||
const { file: mcpPath, keyPath } = mcpTarget('antigravity');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(mcpPath, ['mcpServers', 'gitnexus'], getMcpEntry());
|
||||
const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry());
|
||||
if (ok) {
|
||||
result.configured.push('Antigravity');
|
||||
} else {
|
||||
|
|
@ -590,7 +587,7 @@ async function installAntigravitySkills(result: SetupResult): Promise<void> {
|
|||
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
|
||||
if (!(await dirExists(antigravityDir))) return;
|
||||
|
||||
const skillsDir = path.join(antigravityDir, 'skills');
|
||||
const skillsDir = skillTarget('antigravity').dir;
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
@ -618,9 +615,9 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
|
|||
const antigravityDir = path.join(os.homedir(), '.gemini', 'antigravity');
|
||||
if (!(await dirExists(antigravityDir))) return;
|
||||
|
||||
const geminiDir = path.join(os.homedir(), '.gemini');
|
||||
const settingsPath = path.join(geminiDir, 'settings.json');
|
||||
const destHooksDir = path.join(geminiDir, 'config', 'hooks', 'gitnexus');
|
||||
const antigravityHook = hookTarget('antigravity');
|
||||
const settingsPath = antigravityHook.settingsFile;
|
||||
const destHooksDir = antigravityHook.scriptDir;
|
||||
|
||||
// The antigravity adapter shares its lock/probe helpers with the claude
|
||||
// adapter — same DB, same concurrency rules — so we reuse those CJS files
|
||||
|
|
@ -694,7 +691,7 @@ async function installAntigravityHooks(result: SetupResult): Promise<void> {
|
|||
|
||||
const hookEntries: Array<{ eventName: string; value: unknown }> = [];
|
||||
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', 'gitnexus-antigravity-hook')) {
|
||||
if (!hasGitnexusHook(parsed?.hooks, 'AfterTool', antigravityHook.needle)) {
|
||||
// Matcher follows the Gemini CLI built-in tool naming (snake_case).
|
||||
// search_file_content / glob cover content + filename search; run_shell_command
|
||||
// catches rg/grep invocations and the git commit family for stale-index hints.
|
||||
|
|
@ -742,9 +739,9 @@ async function setupOpenCode(result: SetupResult): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const configPath = path.join(opencodeDir, 'opencode.json');
|
||||
const { file: configPath, keyPath } = mcpTarget('opencode');
|
||||
try {
|
||||
const ok = await mergeJsoncFile(configPath, ['mcp', 'gitnexus'], getOpenCodeMcpEntry());
|
||||
const ok = await mergeJsoncFile(configPath, keyPath, getOpenCodeMcpEntry());
|
||||
if (ok) {
|
||||
result.configured.push('OpenCode');
|
||||
} else {
|
||||
|
|
@ -764,7 +761,7 @@ function getCodexMcpTomlSection(): string {
|
|||
const entry = getMcpEntry();
|
||||
const command = JSON.stringify(entry.command);
|
||||
const args = `[${entry.args.map((arg) => JSON.stringify(arg)).join(', ')}]`;
|
||||
return `[mcp_servers.gitnexus]\ncommand = ${command}\nargs = ${args}\n`;
|
||||
return `[${getEditorTargets().codex.tomlSection}]\ncommand = ${command}\nargs = ${args}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -778,7 +775,7 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
|
|||
existing = '';
|
||||
}
|
||||
|
||||
if (existing.includes('[mcp_servers.gitnexus]')) {
|
||||
if (existing.includes(`[${getEditorTargets().codex.tomlSection}]`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -809,7 +806,7 @@ async function setupCodex(result: SetupResult): Promise<void> {
|
|||
}
|
||||
|
||||
try {
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
const configPath = getEditorTargets().codex.configFile;
|
||||
await upsertCodexConfigToml(configPath);
|
||||
result.configured.push('Codex (MCP added to ~/.codex/config.toml)');
|
||||
} catch (err: any) {
|
||||
|
|
@ -920,7 +917,7 @@ async function installCursorSkills(result: SetupResult): Promise<void> {
|
|||
const cursorDir = path.join(os.homedir(), '.cursor');
|
||||
if (!(await dirExists(cursorDir))) return;
|
||||
|
||||
const skillsDir = path.join(cursorDir, 'skills');
|
||||
const skillsDir = skillTarget('cursor').dir;
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
@ -938,7 +935,7 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
|
|||
const opencodeDir = path.join(os.homedir(), '.config', 'opencode');
|
||||
if (!(await dirExists(opencodeDir))) return;
|
||||
|
||||
const skillsDir = path.join(opencodeDir, 'skills');
|
||||
const skillsDir = skillTarget('opencode').dir;
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
@ -958,7 +955,7 @@ async function installCodexSkills(result: SetupResult): Promise<void> {
|
|||
const codexDir = path.join(os.homedir(), '.codex');
|
||||
if (!(await dirExists(codexDir))) return;
|
||||
|
||||
const skillsDir = path.join(os.homedir(), '.agents', 'skills');
|
||||
const skillsDir = skillTarget('codex').dir;
|
||||
try {
|
||||
const installed = await installSkillsTo(skillsDir);
|
||||
if (installed.length > 0) {
|
||||
|
|
|
|||
518
gitnexus/src/cli/uninstall.ts
Normal file
518
gitnexus/src/cli/uninstall.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
/**
|
||||
* Uninstall Command
|
||||
*
|
||||
* Reverses `gitnexus setup`: removes the GitNexus MCP server entries,
|
||||
* skills, and hooks that setup writes into each detected AI editor's
|
||||
* global configuration. The set of targets (paths, key paths, hook events,
|
||||
* needles, script dirs) is shared with setup.ts via editor-targets.ts, so the
|
||||
* two stay in lock-step.
|
||||
*
|
||||
* Surgical and idempotent: only gitnexus-owned keys/entries/dirs are
|
||||
* removed. Unrelated user config (other MCP servers, other hooks, JSONC
|
||||
* comments, indentation) is preserved. Files that are absent or that
|
||||
* never contained a gitnexus entry are left untouched.
|
||||
*
|
||||
* Ownership is by name: skill directories are matched by the bundled gitnexus
|
||||
* skill names, MCP entries by the `gitnexus` key, hooks by the gitnexus command
|
||||
* needle. There is no per-install provenance marker yet (a user dir that
|
||||
* happens to share a bundled skill name, or files a user added inside an
|
||||
* installed skill dir, are matched purely by name) — which is why uninstall is
|
||||
* a dry-run preview by default and prints the exact paths it will remove.
|
||||
* Richer provenance tracking is a tracked follow-up.
|
||||
*
|
||||
* Intentionally NOT done here (printed as hints instead, since both are
|
||||
* destructive in ways setup never caused):
|
||||
* - per-repo indexes → `gitnexus clean --all`
|
||||
* - the global npm package → `npm uninstall -g gitnexus`
|
||||
*
|
||||
* Default is a dry-run preview; pass --force to apply.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
parseTree,
|
||||
modify,
|
||||
applyEdits,
|
||||
findNodeAtLocation,
|
||||
parse as parseJsonc,
|
||||
type ParseError,
|
||||
type JSONPath,
|
||||
} from 'jsonc-parser';
|
||||
import { getEditorTargets, detectIndentation } from './editor-targets.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface UninstallResult {
|
||||
removed: string[];
|
||||
skipped: string[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type RemovalStatus = 'removed' | 'absent' | 'corrupt' | 'missing';
|
||||
|
||||
/**
|
||||
* Remove a single key (by JSON path) from a JSONC file, preserving the
|
||||
* surrounding comments and formatting. Returns:
|
||||
* - 'missing': file does not exist
|
||||
* - 'absent': file exists but the key isn't there (nothing to do)
|
||||
* - 'corrupt': file isn't valid JSONC — left untouched on purpose
|
||||
* - 'removed': the key was present (and removed unless dryRun)
|
||||
*/
|
||||
async function removeJsoncKey(
|
||||
filePath: string,
|
||||
keyPath: JSONPath,
|
||||
dryRun: boolean,
|
||||
): Promise<RemovalStatus> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (raw.trim().length === 0) return 'absent';
|
||||
|
||||
const parseErrors: ParseError[] = [];
|
||||
const tree = parseTree(raw, parseErrors);
|
||||
if (!tree || tree.type !== 'object' || parseErrors.length > 0) return 'corrupt';
|
||||
|
||||
if (!findNodeAtLocation(tree, keyPath)) return 'absent';
|
||||
|
||||
if (!dryRun) {
|
||||
const formattingOptions = detectIndentation(raw);
|
||||
const edits = modify(raw, keyPath, undefined, { formattingOptions });
|
||||
await fs.writeFile(filePath, applyEdits(raw, edits), 'utf-8');
|
||||
}
|
||||
return 'removed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the gitnexus hook command(s) — those whose command string contains
|
||||
* `commandNeedle` — from the given `eventNames` arrays in a JSONC settings
|
||||
* file. Mirrors the idempotency probes in setup.ts (hasGitnexusHook /
|
||||
* geminiHasGitnexusHook). Returns how many event entries contained a gitnexus
|
||||
* command.
|
||||
*
|
||||
* Removal is element-granular to honor the "other hooks are preserved"
|
||||
* contract: only the matching command object inside an entry's `hooks[]` is
|
||||
* deleted. The surrounding matcher entry is removed only when it becomes
|
||||
* empty (i.e. it held nothing but gitnexus commands — which is exactly what
|
||||
* setup creates). A user who hand-added their own command alongside ours
|
||||
* keeps it. Edits are applied highest-index-first so earlier indices stay
|
||||
* valid across edits.
|
||||
*/
|
||||
async function removeHookEntries(
|
||||
filePath: string,
|
||||
eventNames: string[],
|
||||
commandNeedle: string,
|
||||
dryRun: boolean,
|
||||
): Promise<{ status: RemovalStatus; count: number }> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return { status: 'missing', count: 0 };
|
||||
}
|
||||
|
||||
if (raw.trim().length === 0) return { status: 'absent', count: 0 };
|
||||
|
||||
const parseErrors: ParseError[] = [];
|
||||
const tree = parseTree(raw, parseErrors);
|
||||
if (!tree || tree.type !== 'object' || parseErrors.length > 0) {
|
||||
return { status: 'corrupt', count: 0 };
|
||||
}
|
||||
|
||||
const parsed = parseJsonc(raw);
|
||||
const formattingOptions = detectIndentation(raw);
|
||||
let current = raw;
|
||||
let total = 0;
|
||||
|
||||
const isGitnexusHook = (hh: any): boolean =>
|
||||
typeof hh?.command === 'string' && hh.command.includes(commandNeedle);
|
||||
|
||||
for (const eventName of eventNames) {
|
||||
const entries = parsed?.hooks?.[eventName];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
|
||||
// Walk entries high → low so removing a later one never shifts the
|
||||
// index of an earlier one.
|
||||
for (let entryIdx = entries.length - 1; entryIdx >= 0; entryIdx--) {
|
||||
const entry = entries[entryIdx];
|
||||
if (!Array.isArray(entry?.hooks)) continue;
|
||||
|
||||
const hookIdxs: number[] = [];
|
||||
entry.hooks.forEach((hh: any, hi: number) => {
|
||||
if (isGitnexusHook(hh)) hookIdxs.push(hi);
|
||||
});
|
||||
if (hookIdxs.length === 0) continue;
|
||||
|
||||
total += 1;
|
||||
if (dryRun) continue;
|
||||
|
||||
if (hookIdxs.length === entry.hooks.length) {
|
||||
// The entry held only gitnexus command(s) — drop the whole entry.
|
||||
const edits = modify(current, ['hooks', eventName, entryIdx], undefined, {
|
||||
formattingOptions,
|
||||
});
|
||||
current = applyEdits(current, edits);
|
||||
} else {
|
||||
// The entry also holds user command(s) — delete only ours, keep
|
||||
// the rest. Highest hook index first to keep lower indices valid.
|
||||
for (const hi of hookIdxs.reverse()) {
|
||||
const edits = modify(current, ['hooks', eventName, entryIdx, 'hooks', hi], undefined, {
|
||||
formattingOptions,
|
||||
});
|
||||
current = applyEdits(current, edits);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (total === 0) return { status: 'absent', count: 0 };
|
||||
if (!dryRun) await fs.writeFile(filePath, current, 'utf-8');
|
||||
return { status: 'removed', count: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a directory tree if it exists. Returns true when something was
|
||||
* (or would be) removed.
|
||||
*/
|
||||
async function removeDir(dirPath: string, dryRun: boolean): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(dirPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!dryRun) await fs.rm(dirPath, { recursive: true, force: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact set of skill directory names setup installs, derived from the
|
||||
* bundled `skills/` source the same way installSkillsTo does (flat
|
||||
* `{name}.md` and `{name}/SKILL.md` layouts). Deriving the set — rather
|
||||
* than globbing `gitnexus-*` — ensures we never delete a user's own
|
||||
* similarly-named skill folder.
|
||||
*/
|
||||
async function listGitnexusSkillNames(): Promise<string[]> {
|
||||
const skillsRoot =
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT ?? path.join(__dirname, '..', '..', 'skills');
|
||||
|
||||
const names = new Set<string>();
|
||||
try {
|
||||
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.md')) {
|
||||
// Guard against a bare `.md` file: basename('.md', '.md') === '',
|
||||
// which would later resolve to the skills dir itself and wipe it.
|
||||
const base = path.basename(entry.name, '.md');
|
||||
if (base) names.add(base);
|
||||
} else if (entry.isDirectory()) {
|
||||
try {
|
||||
await fs.access(path.join(skillsRoot, entry.name, 'SKILL.md'));
|
||||
names.add(entry.name);
|
||||
} catch {
|
||||
// Not a skill directory — skip.
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the gitnexus skill directories from a target skills folder. Returns
|
||||
* the absolute paths that were removed (or would be removed in dryRun) so the
|
||||
* caller can show the user exactly what is affected.
|
||||
*/
|
||||
async function removeSkillsFrom(
|
||||
targetDir: string,
|
||||
skillNames: string[],
|
||||
dryRun: boolean,
|
||||
): Promise<string[]> {
|
||||
const removed: string[] = [];
|
||||
for (const name of skillNames) {
|
||||
// Defense in depth: an empty/relative/absolute name would resolve back to
|
||||
// targetDir (or escape it) and wipe unrelated content. Only act on a
|
||||
// plain child directory name.
|
||||
if (
|
||||
!name ||
|
||||
name.includes('/') ||
|
||||
name.includes('\\') ||
|
||||
name === '.' ||
|
||||
name === '..' ||
|
||||
path.isAbsolute(name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const dir = path.join(targetDir, name);
|
||||
if (await removeDir(dir, dryRun)) removed.push(dir);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the `[mcp_servers.gitnexus]` table — and any of its descendant
|
||||
* sub-tables (`[mcp_servers.gitnexus.env]`, `[[mcp_servers.gitnexus.x]]`) —
|
||||
* from Codex's config.toml. Used only as a fallback when the `codex` binary
|
||||
* isn't on PATH; the CLI's `codex mcp remove` is preferred.
|
||||
*
|
||||
* Hand-rolled (no TOML dependency), but careful about the cases a naive
|
||||
* line-scan gets wrong:
|
||||
* - descendant sub-tables of the section are also removed (else they'd be
|
||||
* left dangling, referencing a server that no longer exists);
|
||||
* - `[...]`-shaped lines inside a multiline string (`"""`/`'''`) are NOT
|
||||
* treated as table headers;
|
||||
* - unrelated whitespace/formatting elsewhere in the file is left intact
|
||||
* (no global blank-line reflow). Only a single blank separator line
|
||||
* directly above the removed section is dropped.
|
||||
*/
|
||||
function stripTomlSection(raw: string, sectionName: string): string {
|
||||
const header = `[${sectionName}]`;
|
||||
const childTable = `[${sectionName}.`;
|
||||
const childArray = `[[${sectionName}.`;
|
||||
// Capture group 1 is the bracket token only, so a trailing inline comment
|
||||
// (`[mcp_servers.gitnexus] # note`) is stripped before classification —
|
||||
// otherwise an exact `=== header` check fails and the section is left behind.
|
||||
const headerRe = /^(\[\[?[^[\]]+\]\]?)\s*(#.*)?$/;
|
||||
|
||||
const isSectionHeader = (token: string): boolean =>
|
||||
token === header || token.startsWith(childTable) || token.startsWith(childArray);
|
||||
|
||||
// Return the multiline-string delimiter still OPEN at the end of `line`,
|
||||
// given the state at its start (null = outside any multiline string). Scans
|
||||
// left→right so the delimiter that actually opens first wins — a line with an
|
||||
// odd count of BOTH `"""` and `'''` (e.g. `x = '''has """ inside`) no longer
|
||||
// mis-picks the wrong delimiter and desyncs the scanner.
|
||||
const multilineStateAfter = (line: string, startState: string | null): string | null => {
|
||||
let state = startState;
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (state) {
|
||||
const close = line.indexOf(state, i);
|
||||
if (close === -1) return state; // still open at end of line
|
||||
i = close + state.length;
|
||||
state = null;
|
||||
} else {
|
||||
const a = line.indexOf('"""', i);
|
||||
const b = line.indexOf("'''", i);
|
||||
if (a === -1 && b === -1) return null;
|
||||
const useA = b === -1 || (a !== -1 && a < b);
|
||||
state = useA ? '"""' : "'''";
|
||||
i = (useA ? a : b) + 3;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const out: string[] = [];
|
||||
let skipping = false;
|
||||
let mlDelim: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
if (mlDelim) {
|
||||
// Inside a multiline string: brackets here are data, not headers.
|
||||
mlDelim = multilineStateAfter(line, mlDelim);
|
||||
if (!skipping) out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
const headerMatch = trimmed.match(headerRe);
|
||||
if (headerMatch) {
|
||||
if (isSectionHeader(headerMatch[1])) {
|
||||
// Drop a single blank separator line immediately above the section.
|
||||
if (!skipping && out.length > 0 && out[out.length - 1].trim() === '') out.pop();
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
// A non-descendant header ends the section.
|
||||
skipping = false;
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track whether this (non-header) line opens a multiline string so a
|
||||
// bracketed line inside it isn't mistaken for a header.
|
||||
mlDelim = multilineStateAfter(line, null);
|
||||
|
||||
if (!skipping) out.push(line);
|
||||
}
|
||||
|
||||
// Preserve the file's line endings: a CRLF (Windows) config.toml should not
|
||||
// be silently rewritten to LF. Rejoin with the dominant EOL of the input.
|
||||
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
||||
let result = out.join(eol);
|
||||
if (!result.endsWith(eol)) result += eol;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function uninstallCodex(
|
||||
result: UninstallResult,
|
||||
dryRun: boolean,
|
||||
configPath: string,
|
||||
tomlSection: string,
|
||||
): Promise<void> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(configPath, 'utf-8');
|
||||
} catch {
|
||||
result.skipped.push('Codex MCP (not configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!raw.includes(`[${tomlSection}]`)) {
|
||||
result.skipped.push('Codex MCP (not configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the official CLI (mirrors setup's `codex mcp add`); fall back
|
||||
// to editing config.toml directly when the binary isn't on PATH.
|
||||
try {
|
||||
await execFileAsync('codex', ['mcp', 'remove', 'gitnexus'], {
|
||||
shell: process.platform === 'win32',
|
||||
windowsHide: true,
|
||||
timeout: 10000,
|
||||
});
|
||||
result.removed.push("Codex MCP server — via 'codex mcp remove gitnexus'");
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to manual edit.
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.writeFile(configPath, stripTomlSection(raw, tomlSection), 'utf-8');
|
||||
result.removed.push(`Codex MCP server — [${tomlSection}] in ${configPath}`);
|
||||
} catch (err: any) {
|
||||
result.errors.push(`Codex: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main command ──────────────────────────────────────────────────
|
||||
|
||||
export const uninstallCommand = async (options?: { force?: boolean }) => {
|
||||
const dryRun = !options?.force;
|
||||
const targets = getEditorTargets();
|
||||
|
||||
console.log('');
|
||||
console.log(' GitNexus Uninstall');
|
||||
console.log(' ==================');
|
||||
console.log('');
|
||||
if (dryRun) {
|
||||
console.log(' Dry run — nothing will be changed. Re-run with --force to apply.');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
const result: UninstallResult = { removed: [], skipped: [], errors: [] };
|
||||
|
||||
// ─── MCP server entries (JSONC editors) ──────────────────────────
|
||||
for (const target of targets.mcpJsonc) {
|
||||
try {
|
||||
const status = await removeJsoncKey(target.file, target.keyPath, dryRun);
|
||||
if (status === 'removed')
|
||||
result.removed.push(
|
||||
`${target.label} MCP server — ${target.keyPath.join('.')} in ${target.file}`,
|
||||
);
|
||||
else if (status === 'corrupt')
|
||||
result.errors.push(
|
||||
`${target.label}: ${path.basename(target.file)} is corrupt — left untouched`,
|
||||
);
|
||||
else result.skipped.push(`${target.label} MCP (not configured)`);
|
||||
} catch (err: any) {
|
||||
result.errors.push(`${target.label}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await uninstallCodex(result, dryRun, targets.codex.configFile, targets.codex.tomlSection);
|
||||
|
||||
// ─── Hooks ───────────────────────────────────────────────────────
|
||||
for (const hook of targets.hooks) {
|
||||
try {
|
||||
const { status, count } = await removeHookEntries(
|
||||
hook.settingsFile,
|
||||
hook.events,
|
||||
hook.needle,
|
||||
dryRun,
|
||||
);
|
||||
if (status === 'removed')
|
||||
result.removed.push(`${hook.label} hooks (${count}) — ${hook.settingsFile}`);
|
||||
else if (status === 'corrupt')
|
||||
result.errors.push(
|
||||
`${hook.label} hooks: ${path.basename(hook.settingsFile)} is corrupt — left untouched`,
|
||||
);
|
||||
// Don't delete the hook script while a registered entry may still point
|
||||
// at it (corrupt = we couldn't parse/remove the entry) — that would
|
||||
// leave the editor invoking a missing script on every matched tool call.
|
||||
if (status !== 'corrupt' && (await removeDir(hook.scriptDir, dryRun)))
|
||||
result.removed.push(`${hook.label} hook scripts — ${hook.scriptDir}`);
|
||||
} catch (err: any) {
|
||||
result.errors.push(`${hook.label} hooks: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Skills ──────────────────────────────────────────────────────
|
||||
// Skill directories are identified by the bundled gitnexus skill names; the
|
||||
// exact paths are listed below so the user can see what will be removed.
|
||||
const skillNames = await listGitnexusSkillNames();
|
||||
for (const target of targets.skills) {
|
||||
try {
|
||||
const removedDirs = await removeSkillsFrom(target.dir, skillNames, dryRun);
|
||||
for (const dir of removedDirs) result.removed.push(`${target.label} skill — ${dir}`);
|
||||
} catch (err: any) {
|
||||
result.errors.push(`${target.label} skills: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Report ──────────────────────────────────────────────────────
|
||||
const verb = dryRun ? 'Would remove' : 'Removed';
|
||||
if (result.removed.length > 0) {
|
||||
console.log(` ${verb}:`);
|
||||
for (const name of result.removed) console.log(` - ${name}`);
|
||||
} else {
|
||||
console.log(' Nothing to remove — GitNexus is not configured in any detected editor.');
|
||||
}
|
||||
|
||||
if (result.skipped.length > 0) {
|
||||
console.log('');
|
||||
console.log(' Skipped:');
|
||||
for (const name of result.skipped) console.log(` - ${name}`);
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.log('');
|
||||
console.log(' Errors:');
|
||||
for (const err of result.errors) console.log(` ! ${err}`);
|
||||
// Signal partial failure to callers/CI without aborting the remaining
|
||||
// cleanup (which has already run by this point).
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(' Note: skill directories are matched by bundled gitnexus skill name. If you');
|
||||
console.log(' customized files inside an installed skill dir, back them up before --force.');
|
||||
|
||||
console.log('');
|
||||
console.log(' Not removed automatically:');
|
||||
console.log(' - Per-repo indexes — run: gitnexus clean --all');
|
||||
console.log(' - The global npm package — run: npm uninstall -g gitnexus');
|
||||
|
||||
if (dryRun && result.removed.length > 0) {
|
||||
console.log('');
|
||||
console.log(' Re-run with --force to apply the changes above.');
|
||||
}
|
||||
console.log('');
|
||||
};
|
||||
215
gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
Normal file
215
gitnexus/test/integration/setup-uninstall-roundtrip.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { getEditorTargets } from '../../src/cli/editor-targets.js';
|
||||
|
||||
// Force the Codex path through the TOML fallback (no `codex` binary) so the
|
||||
// round-trip is observable on config.toml, and make `which/where gitnexus`
|
||||
// miss so getMcpEntry uses the npx form. Mirrors the unit-test mocks.
|
||||
const execFileMock = vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
if (typeof callback === 'function') callback(new Error('not available'), '', '');
|
||||
});
|
||||
const execFileSyncMock = vi.fn(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
}));
|
||||
|
||||
/** Read a value at a JSON key path, or undefined if any segment is missing. */
|
||||
function valueAtPath(obj: any, keyPath: string[]): unknown {
|
||||
return keyPath.reduce((o: any, k) => (o == null ? undefined : o[k]), obj);
|
||||
}
|
||||
|
||||
/** Does any of `events` hold a hook entry whose command contains `needle`? */
|
||||
function hasHookNeedle(settings: any, events: string[], needle: string): boolean {
|
||||
return events.some(
|
||||
(ev) =>
|
||||
Array.isArray(settings?.hooks?.[ev]) &&
|
||||
settings.hooks[ev].some(
|
||||
(entry: any) =>
|
||||
Array.isArray(entry?.hooks) &&
|
||||
entry.hooks.some(
|
||||
(h: any) => typeof h?.command === 'string' && h.command.includes(needle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function exists(p: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readJsonc(p: string): Promise<any> {
|
||||
return parseJsonc(await fs.readFile(p, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* setup → uninstall round-trip. This is the drift tripwire for #2062: it
|
||||
* iterates over getEditorTargets() (the shared source of truth that both
|
||||
* setup.ts and uninstall.ts consume), so if one side gains/loses/relocates a
|
||||
* target without the other following, this fails in CI — in both directions.
|
||||
*/
|
||||
describe('setup → uninstall round-trip', () => {
|
||||
let tempHome: string;
|
||||
let skillsRoot: string;
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
let savedExitCode: typeof process.exitCode;
|
||||
|
||||
// Two fixture skills exercise both source layouts (flat + directory).
|
||||
const flatSkill = 'gitnexus-roundtrip-flat';
|
||||
const dirSkill = 'gitnexus-roundtrip-dir';
|
||||
const skillNames = [flatSkill, dirSkill];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
saved.HOME = process.env.HOME;
|
||||
saved.USERPROFILE = process.env.USERPROFILE;
|
||||
saved.SKILLS = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
savedExitCode = process.exitCode;
|
||||
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-roundtrip-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
||||
// Mark every editor as "installed" so setup configures all of them.
|
||||
for (const dir of ['.cursor', '.claude', '.codex']) {
|
||||
await fs.mkdir(path.join(tempHome, dir), { recursive: true });
|
||||
}
|
||||
await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true });
|
||||
await fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true });
|
||||
|
||||
// Fixture skills consumed by both setup (install) and uninstall (derive).
|
||||
skillsRoot = path.join(tempHome, 'pkg-skills');
|
||||
await fs.mkdir(path.join(skillsRoot, dirSkill), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(skillsRoot, `${flatSkill}.md`),
|
||||
`---\nname: ${flatSkill}\ndescription: flat\n---\n\n# Flat`,
|
||||
'utf-8',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(skillsRoot, dirSkill, 'SKILL.md'),
|
||||
`---\nname: ${dirSkill}\ndescription: dir\n---\n\n# Dir`,
|
||||
'utf-8',
|
||||
);
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
|
||||
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
process.env.HOME = saved.HOME;
|
||||
process.env.USERPROFILE = saved.USERPROFILE;
|
||||
if (saved.SKILLS === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
else process.env.GITNEXUS_TEST_SKILLS_ROOT = saved.SKILLS;
|
||||
process.exitCode = savedExitCode;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('setup writes every target and uninstall removes all of them', async () => {
|
||||
const targets = getEditorTargets(tempHome);
|
||||
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
// ── After setup: every target artifact is present ──
|
||||
for (const t of targets.mcpJsonc) {
|
||||
const cfg = await readJsonc(t.file);
|
||||
expect(valueAtPath(cfg, t.keyPath), `setup should write ${t.label} MCP`).toBeDefined();
|
||||
}
|
||||
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).toContain(
|
||||
`[${targets.codex.tomlSection}]`,
|
||||
);
|
||||
for (const t of targets.skills) {
|
||||
for (const name of skillNames) {
|
||||
expect(
|
||||
await exists(path.join(t.dir, name, 'SKILL.md')),
|
||||
`setup should install ${name} into ${t.label}`,
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
for (const h of targets.hooks) {
|
||||
const settings = await readJsonc(h.settingsFile);
|
||||
expect(
|
||||
hasHookNeedle(settings, h.events, h.needle),
|
||||
`setup should register ${h.label} hook`,
|
||||
).toBe(true);
|
||||
expect(await exists(h.scriptDir), `setup should install ${h.label} hook scripts`).toBe(true);
|
||||
}
|
||||
|
||||
// ── Round-trip: uninstall removes everything setup wrote ──
|
||||
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
for (const t of targets.mcpJsonc) {
|
||||
const cfg = await readJsonc(t.file);
|
||||
expect(valueAtPath(cfg, t.keyPath), `uninstall should remove ${t.label} MCP`).toBeUndefined();
|
||||
}
|
||||
expect(await fs.readFile(targets.codex.configFile, 'utf-8')).not.toContain(
|
||||
`[${targets.codex.tomlSection}]`,
|
||||
);
|
||||
for (const t of targets.skills) {
|
||||
for (const name of skillNames) {
|
||||
expect(
|
||||
await exists(path.join(t.dir, name)),
|
||||
`uninstall should remove ${name} from ${t.label}`,
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
for (const h of targets.hooks) {
|
||||
const settings = await readJsonc(h.settingsFile);
|
||||
expect(
|
||||
hasHookNeedle(settings, h.events, h.needle),
|
||||
`uninstall should remove ${h.label} hook`,
|
||||
).toBe(false);
|
||||
expect(await exists(h.scriptDir), `uninstall should remove ${h.label} hook scripts`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uninstall preserves a co-located user MCP server and hook', async () => {
|
||||
const targets = getEditorTargets(tempHome);
|
||||
const { setupCommand } = await import('../../src/cli/setup.js');
|
||||
await setupCommand();
|
||||
|
||||
// Add a user-owned MCP server alongside gitnexus in Cursor's config, and a
|
||||
// user hook alongside gitnexus in Claude's PreToolUse.
|
||||
const cursor = targets.mcpJsonc.find((t) => t.id === 'cursor')!;
|
||||
const cursorCfg = await readJsonc(cursor.file);
|
||||
cursorCfg.mcpServers.mine = { command: 'mine' };
|
||||
await fs.writeFile(cursor.file, JSON.stringify(cursorCfg, null, 2), 'utf-8');
|
||||
|
||||
const claudeHook = targets.hooks.find((h) => h.id === 'claude')!;
|
||||
const settings = await readJsonc(claudeHook.settingsFile);
|
||||
settings.hooks.PreToolUse.push({
|
||||
matcher: 'Read',
|
||||
hooks: [{ type: 'command', command: 'my-own-hook' }],
|
||||
});
|
||||
await fs.writeFile(claudeHook.settingsFile, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
|
||||
const { uninstallCommand } = await import('../../src/cli/uninstall.js');
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const afterCursor = await readJsonc(cursor.file);
|
||||
expect(afterCursor.mcpServers.gitnexus).toBeUndefined();
|
||||
expect(afterCursor.mcpServers.mine).toEqual({ command: 'mine' });
|
||||
|
||||
const afterSettings = await readJsonc(claudeHook.settingsFile);
|
||||
const userHookSurvives = afterSettings.hooks.PreToolUse.some((e: any) =>
|
||||
e.hooks?.some((h: any) => h.command === 'my-own-hook'),
|
||||
);
|
||||
expect(userHookSurvives).toBe(true);
|
||||
expect(hasHookNeedle(afterSettings, claudeHook.events, claudeHook.needle)).toBe(false);
|
||||
});
|
||||
});
|
||||
561
gitnexus/test/unit/uninstall.test.ts
Normal file
561
gitnexus/test/unit/uninstall.test.ts
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs/promises';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Codex uninstall shells out to `codex mcp remove`; make it fail by default
|
||||
// so the TOML-strip fallback path is exercised in tests that don't override it.
|
||||
const execFileMock = vi.fn((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
if (typeof callback === 'function') {
|
||||
callback(new Error('codex not found'), '', '');
|
||||
}
|
||||
});
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
execFile: execFileMock,
|
||||
}));
|
||||
|
||||
describe('uninstallCommand', () => {
|
||||
let tempHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalUserProfile: string | undefined;
|
||||
let originalSkillsRoot: string | undefined;
|
||||
let originalExitCode: typeof process.exitCode;
|
||||
let skillsRoot: string;
|
||||
|
||||
const importUninstall = async () => (await import('../../src/cli/uninstall.js')).uninstallCommand;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
|
||||
originalHome = process.env.HOME;
|
||||
originalUserProfile = process.env.USERPROFILE;
|
||||
originalSkillsRoot = process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
originalExitCode = process.exitCode;
|
||||
|
||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-uninstall-'));
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
|
||||
// Stage a fixture skills source so listGitnexusSkillNames() resolves
|
||||
// deterministically without depending on __dirname under Vitest.
|
||||
skillsRoot = path.join(tempHome, 'pkg-skills');
|
||||
await fs.mkdir(skillsRoot, { recursive: true });
|
||||
await fs.writeFile(path.join(skillsRoot, 'gitnexus-exploring.md'), '# explore', 'utf-8');
|
||||
await fs.writeFile(path.join(skillsRoot, 'gitnexus-cli.md'), '# cli', 'utf-8');
|
||||
process.env.GITNEXUS_TEST_SKILLS_ROOT = skillsRoot;
|
||||
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
process.env.HOME = originalHome;
|
||||
process.env.USERPROFILE = originalUserProfile;
|
||||
if (originalSkillsRoot === undefined) delete process.env.GITNEXUS_TEST_SKILLS_ROOT;
|
||||
else process.env.GITNEXUS_TEST_SKILLS_ROOT = originalSkillsRoot;
|
||||
// The command sets process.exitCode=1 on partial failure; restore it so a
|
||||
// test exercising that path doesn't make the whole vitest run exit non-zero.
|
||||
process.exitCode = originalExitCode;
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes the gitnexus MCP entry from ~/.claude.json, preserving others', async () => {
|
||||
const claudeJson = path.join(tempHome, '.claude.json');
|
||||
await fs.writeFile(
|
||||
claudeJson,
|
||||
JSON.stringify({
|
||||
existingKey: 'keep-me',
|
||||
mcpServers: {
|
||||
gitnexus: { command: 'gitnexus', args: ['mcp'] },
|
||||
other: { command: 'foo' },
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const config = JSON.parse(await fs.readFile(claudeJson, 'utf-8'));
|
||||
expect(config.mcpServers.gitnexus).toBeUndefined();
|
||||
expect(config.mcpServers.other).toEqual({ command: 'foo' });
|
||||
expect(config.existingKey).toBe('keep-me');
|
||||
});
|
||||
|
||||
it('dry run (no --force) leaves files untouched', async () => {
|
||||
const claudeJson = path.join(tempHome, '.claude.json');
|
||||
const raw = JSON.stringify({
|
||||
mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } },
|
||||
});
|
||||
await fs.writeFile(claudeJson, raw, 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand();
|
||||
|
||||
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(raw);
|
||||
});
|
||||
|
||||
it('removes gitnexus hook entries and the hook-script dir, preserving other hooks', async () => {
|
||||
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
||||
},
|
||||
{ matcher: 'Read', hooks: [{ type: 'command', command: 'my-own-hook' }] },
|
||||
],
|
||||
PostToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
||||
await fs.mkdir(hookDir, { recursive: true });
|
||||
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
||||
expect(config.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-hook');
|
||||
expect(config.hooks.PostToolUse).toHaveLength(0);
|
||||
await expect(fs.access(hookDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('removes installed gitnexus skill directories from ~/.claude/skills', async () => {
|
||||
const skillsDir = path.join(tempHome, '.claude', 'skills');
|
||||
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
|
||||
await fs.mkdir(path.join(skillsDir, 'gitnexus-cli'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'gitnexus-cli', 'SKILL.md'), '# y', 'utf-8');
|
||||
// A user's own skill that must survive.
|
||||
await fs.mkdir(path.join(skillsDir, 'my-skill'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'my-skill', 'SKILL.md'), '# mine', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(skillsDir, 'gitnexus-cli'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(skillsDir, 'my-skill'))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips the [mcp_servers.gitnexus] section from Codex config.toml, keeping other tables', async () => {
|
||||
const codexDir = path.join(tempHome, '.codex');
|
||||
await fs.mkdir(codexDir, { recursive: true });
|
||||
const configPath = path.join(codexDir, 'config.toml');
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
'command = "other"',
|
||||
'args = ["mcp"]',
|
||||
'',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'command = "gitnexus"',
|
||||
'args = ["mcp"]',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const result = await fs.readFile(configPath, 'utf-8');
|
||||
expect(result).not.toContain('[mcp_servers.gitnexus]');
|
||||
expect(result).toContain('[mcp_servers.other]');
|
||||
expect(result).toContain('command = "other"');
|
||||
});
|
||||
|
||||
it('leaves a corrupt JSON config untouched', async () => {
|
||||
const claudeJson = path.join(tempHome, '.claude.json');
|
||||
const corrupt = '{ not valid json !!!';
|
||||
await fs.writeFile(claudeJson, corrupt, 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
expect(await fs.readFile(claudeJson, 'utf-8')).toBe(corrupt);
|
||||
});
|
||||
|
||||
it('is a no-op when nothing is configured', async () => {
|
||||
const uninstallCommand = await importUninstall();
|
||||
await expect(uninstallCommand({ force: true })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ── #1: empty derived skill name must not wipe the whole skills dir ──
|
||||
it('does not wipe the skills dir when the source has a bare ".md" file', async () => {
|
||||
// A stray bare ".md" → basename('.md', '.md') === '' → would resolve to
|
||||
// the skills dir itself if unguarded.
|
||||
await fs.writeFile(path.join(skillsRoot, '.md'), 'stray', 'utf-8');
|
||||
|
||||
const skillsDir = path.join(tempHome, '.claude', 'skills');
|
||||
await fs.mkdir(path.join(skillsDir, 'gitnexus-exploring'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'gitnexus-exploring', 'SKILL.md'), '# x', 'utf-8');
|
||||
await fs.mkdir(path.join(skillsDir, 'user-skill'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'user-skill', 'SKILL.md'), '# mine', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
// The skills dir and the user skill survive; only the gitnexus skill went.
|
||||
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(skillsDir, 'user-skill'))).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(skillsDir, 'gitnexus-exploring'))).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── #2: corrupt settings.json must not orphan the hook script ──
|
||||
it('keeps the hook-script dir when settings.json is corrupt (avoids dangling hook)', async () => {
|
||||
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
||||
const corrupt = '{ not valid json !!!';
|
||||
await fs.writeFile(settingsPath, corrupt, 'utf-8');
|
||||
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
||||
await fs.mkdir(hookDir, { recursive: true });
|
||||
await fs.writeFile(path.join(hookDir, 'gitnexus-hook.cjs'), '// hook', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
// Entry couldn't be removed → script dir must stay, and we flag failure.
|
||||
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(corrupt);
|
||||
await expect(fs.access(hookDir)).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
// ── #3: element-granular hook removal preserves a co-located user hook ──
|
||||
it('removes only the gitnexus command from a shared hook entry, keeping user commands', async () => {
|
||||
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [
|
||||
{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' },
|
||||
{ type: 'command', command: 'my-own-inline-hook' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const config = JSON.parse(await fs.readFile(settingsPath, 'utf-8'));
|
||||
// Entry survives because it still holds the user's command.
|
||||
expect(config.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(config.hooks.PreToolUse[0].hooks).toHaveLength(1);
|
||||
expect(config.hooks.PreToolUse[0].hooks[0].command).toBe('my-own-inline-hook');
|
||||
});
|
||||
|
||||
// ── OpenCode MCP uses a distinct keyPath: ['mcp', 'gitnexus'] ──
|
||||
it('removes the gitnexus entry from OpenCode opencode.json (mcp.gitnexus), preserving others', async () => {
|
||||
const opencodeJson = path.join(tempHome, '.config', 'opencode', 'opencode.json');
|
||||
await fs.mkdir(path.dirname(opencodeJson), { recursive: true });
|
||||
await fs.writeFile(
|
||||
opencodeJson,
|
||||
JSON.stringify({
|
||||
mcp: {
|
||||
gitnexus: { type: 'local', command: ['gitnexus', 'mcp'] },
|
||||
other: { type: 'local', command: ['foo'] },
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const config = JSON.parse(await fs.readFile(opencodeJson, 'utf-8'));
|
||||
expect(config.mcp.gitnexus).toBeUndefined();
|
||||
expect(config.mcp.other).toEqual({ type: 'local', command: ['foo'] });
|
||||
});
|
||||
|
||||
// ── Antigravity MCP + hooks (AfterTool / gitnexus-antigravity-hook) ──
|
||||
it('removes Antigravity MCP and AfterTool hooks plus the adapter script dir', async () => {
|
||||
const mcpPath = path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json');
|
||||
await fs.mkdir(path.dirname(mcpPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
mcpPath,
|
||||
JSON.stringify({ mcpServers: { gitnexus: { command: 'gitnexus', args: ['mcp'] } } }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const geminiSettings = path.join(tempHome, '.gemini', 'settings.json');
|
||||
await fs.writeFile(
|
||||
geminiSettings,
|
||||
JSON.stringify({
|
||||
hooks: {
|
||||
AfterTool: [
|
||||
{
|
||||
matcher: 'search_file_content|glob|run_shell_command',
|
||||
hooks: [{ type: 'command', command: 'node ".../gitnexus-antigravity-hook.cjs"' }],
|
||||
},
|
||||
{ matcher: 'glob', hooks: [{ type: 'command', command: 'user-after-tool' }] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
);
|
||||
const adapterDir = path.join(tempHome, '.gemini', 'config', 'hooks', 'gitnexus');
|
||||
await fs.mkdir(adapterDir, { recursive: true });
|
||||
await fs.writeFile(path.join(adapterDir, 'gitnexus-antigravity-hook.cjs'), '// a', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
expect(JSON.parse(await fs.readFile(mcpPath, 'utf-8')).mcpServers.gitnexus).toBeUndefined();
|
||||
const settings = JSON.parse(await fs.readFile(geminiSettings, 'utf-8'));
|
||||
expect(settings.hooks.AfterTool).toHaveLength(1);
|
||||
expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('user-after-tool');
|
||||
await expect(fs.access(adapterDir)).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ── Codex success path: prefer `codex mcp remove`, don't touch the file ──
|
||||
it('uses `codex mcp remove` when the binary succeeds and leaves config.toml untouched', async () => {
|
||||
execFileMock.mockImplementationOnce((...args: any[]) => {
|
||||
const callback = args.at(-1);
|
||||
if (typeof callback === 'function') callback(null, '', '');
|
||||
});
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
const raw = ['[mcp_servers.gitnexus]', 'command = "gitnexus"', 'args = ["mcp"]', ''].join('\n');
|
||||
await fs.writeFile(configPath, raw, 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledWith(
|
||||
'codex',
|
||||
['mcp', 'remove', 'gitnexus'],
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
// CLI path handles removal; we must not also rewrite the file.
|
||||
expect(await fs.readFile(configPath, 'utf-8')).toBe(raw);
|
||||
});
|
||||
|
||||
// ── #5: TOML fallback also strips descendant sub-tables ──
|
||||
it('strips a [mcp_servers.gitnexus.env] sub-table along with the parent', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
'command = "other"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'command = "gitnexus"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus.env]',
|
||||
'FOO = "bar"',
|
||||
'',
|
||||
'[mcp_servers.zeta]',
|
||||
'command = "zeta"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const out = await fs.readFile(configPath, 'utf-8');
|
||||
expect(out).not.toContain('[mcp_servers.gitnexus]');
|
||||
expect(out).not.toContain('[mcp_servers.gitnexus.env]');
|
||||
expect(out).not.toContain('FOO = "bar"');
|
||||
expect(out).toContain('[mcp_servers.other]');
|
||||
expect(out).toContain('[mcp_servers.zeta]');
|
||||
expect(out).toContain('command = "zeta"');
|
||||
});
|
||||
|
||||
// ── #5: TOML fallback ignores a [header] inside a multiline string ──
|
||||
it('does not truncate a multiline string that contains a [mcp_servers.gitnexus] literal', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
'note = """',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'not a real header',
|
||||
'"""',
|
||||
'command = "other"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'command = "gitnexus"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const out = await fs.readFile(configPath, 'utf-8');
|
||||
// The multiline literal is preserved...
|
||||
expect(out).toContain('not a real header');
|
||||
expect(out).toContain('command = "other"');
|
||||
// ...but the real section's body is gone.
|
||||
expect(out).not.toContain('command = "gitnexus"');
|
||||
});
|
||||
|
||||
// ── #5 (regression): a multiline line containing an odd count of BOTH
|
||||
// delimiters must not desync the scanner (it previously stuck in multiline
|
||||
// mode and failed to strip the real section). ──
|
||||
it('strips the real section even when a multiline string mixes \'\'\' and """ on one line', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
// Opens a ''' literal; the """ on this same line is data, not an opener.
|
||||
'note = \'\'\'has """ inside',
|
||||
'still in string [mcp_servers.gitnexus]',
|
||||
"'''",
|
||||
'command = "other"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'command = "gitnexus"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const out = await fs.readFile(configPath, 'utf-8');
|
||||
// The multiline literal (incl. the fake header line) is preserved...
|
||||
expect(out).toContain('still in string [mcp_servers.gitnexus]');
|
||||
expect(out).toContain('command = "other"');
|
||||
// ...and the real section was actually removed (the bug left it behind).
|
||||
expect(out).not.toContain('command = "gitnexus"');
|
||||
});
|
||||
|
||||
// ── sweep: a section header with a trailing inline comment is still stripped ──
|
||||
it('strips a [mcp_servers.gitnexus] header that has a trailing inline comment', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
'command = "other"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus] # GitNexus MCP',
|
||||
'command = "gitnexus"',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const out = await fs.readFile(configPath, 'utf-8');
|
||||
expect(out).not.toContain('mcp_servers.gitnexus');
|
||||
expect(out).not.toContain('command = "gitnexus"');
|
||||
expect(out).toContain('[mcp_servers.other]');
|
||||
});
|
||||
|
||||
// ── sweep: CRLF config.toml keeps its line endings (no silent LF rewrite) ──
|
||||
it('preserves CRLF line endings when stripping the Codex section', async () => {
|
||||
const configPath = path.join(tempHome, '.codex', 'config.toml');
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
[
|
||||
'[mcp_servers.other]',
|
||||
'command = "other"',
|
||||
'',
|
||||
'[mcp_servers.gitnexus]',
|
||||
'command = "gitnexus"',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
const out = await fs.readFile(configPath, 'utf-8');
|
||||
expect(out).not.toContain('[mcp_servers.gitnexus]');
|
||||
expect(out).toContain('[mcp_servers.other]');
|
||||
expect(out).toContain('\r\n');
|
||||
// No bare LF: every newline is part of a CRLF.
|
||||
expect(out).not.toMatch(/[^\r]\n/);
|
||||
});
|
||||
|
||||
// ── dry-run leaves hooks and skills intact ──
|
||||
it('dry run does not remove hooks, hook scripts, or skills', async () => {
|
||||
const settingsPath = path.join(tempHome, '.claude', 'settings.json');
|
||||
await fs.mkdir(path.join(tempHome, '.claude'), { recursive: true });
|
||||
const settingsRaw = JSON.stringify({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: 'Bash',
|
||||
hooks: [{ type: 'command', command: 'node ".../gitnexus-hook.cjs"' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
await fs.writeFile(settingsPath, settingsRaw, 'utf-8');
|
||||
const hookDir = path.join(tempHome, '.claude', 'hooks', 'gitnexus');
|
||||
await fs.mkdir(hookDir, { recursive: true });
|
||||
const skillsDir = path.join(tempHome, '.claude', 'skills', 'gitnexus-cli');
|
||||
await fs.mkdir(skillsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(skillsDir, 'SKILL.md'), '# y', 'utf-8');
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand(); // no --force
|
||||
|
||||
expect(await fs.readFile(settingsPath, 'utf-8')).toBe(settingsRaw);
|
||||
await expect(fs.access(hookDir)).resolves.toBeUndefined();
|
||||
await expect(fs.access(skillsDir)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
// ── skills: directory-layout source ({name}/SKILL.md) is recognized ──
|
||||
it('removes a directory-layout skill ({name}/SKILL.md) from a target', async () => {
|
||||
await fs.mkdir(path.join(skillsRoot, 'gitnexus-dir-skill'), { recursive: true });
|
||||
await fs.writeFile(path.join(skillsRoot, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
|
||||
|
||||
// Use a non-Claude target to also exercise a second skill destination.
|
||||
const opencodeSkills = path.join(tempHome, '.config', 'opencode', 'skills');
|
||||
await fs.mkdir(path.join(opencodeSkills, 'gitnexus-dir-skill'), { recursive: true });
|
||||
await fs.writeFile(path.join(opencodeSkills, 'gitnexus-dir-skill', 'SKILL.md'), '# d', 'utf-8');
|
||||
await fs.mkdir(path.join(opencodeSkills, 'keep-me'), { recursive: true });
|
||||
|
||||
const uninstallCommand = await importUninstall();
|
||||
await uninstallCommand({ force: true });
|
||||
|
||||
await expect(fs.access(path.join(opencodeSkills, 'gitnexus-dir-skill'))).rejects.toThrow();
|
||||
await expect(fs.access(path.join(opencodeSkills, 'keep-me'))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue