mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / ci (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* feat(setup): install Codex PreToolUse/PostToolUse hooks (#2328) Codex CLI supports lifecycle hooks with Claude Code's exact {hooks: {Event: [...]}} JSON schema, stdin payload, and hookSpecificOutput response contract, registered in a dedicated ~/.codex/hooks.json (https://developers.openai.com/codex/hooks). Parameterize installClaudeCodeHooks into installClaudeSchemaHooks (claude | codex): both runtimes share the installer, the bundled gitnexus-hook.cjs adapter, and its helpers. A codex HookTarget in editor-targets.ts makes uninstall and the setup-uninstall round-trip tripwire cover the new surface with no uninstall.ts changes. SessionStart is deliberately not registered: Codex reads AGENTS.md natively, which already carries the GitNexus context block. Closes #2328. Closes #244 (Codex setup support is now complete: MCP + skills + hooks). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(plugin): make the GitNexus plugin installable from Codex (#1131) Codex's plugin system (https://developers.openai.com/codex/plugins/build) reads a .codex-plugin/plugin.json manifest and a repo-root .agents/plugins/marketplace.json registry. The existing gitnexus-claude-plugin/ is already Codex-compatible as-is — Codex sets CLAUDE_PLUGIN_ROOT for hook-command compatibility, loads the same SKILL.md skills, hooks/hooks.json, and .mcp.json — so a second manifest in the same folder replaces PR #1131's duplicated plugin tree with zero copied skills or hooks. The .gitignore .agents/ scratch rule narrows to re-include only the registry file. Install: codex plugin marketplace add abhigyanpatwari/GitNexus Supersedes #1131. Co-authored-by: jublin <1799126+jublin@users.noreply.github.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: document Codex full support (MCP + skills + hooks + plugin) Promote Codex to Full in both editor tables, document the ~/.codex/hooks.json hook install, and add the Codex plugin marketplace install path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(release): extend the version-lockstep guard to the Codex manifests The always-on drift guard asserted only the Claude plugin manifests against gitnexus/package.json, so a release could ship stale versions in .codex-plugin/plugin.json and .agents/plugins/marketplace.json without CI noticing. Mirror the Claude lockstep test for the two Codex files and extend the CONTRIBUTING §Releases lockstep list to match. Verified guard semantics: a deliberate local version mutation of the Codex marketplace entry turns the new test red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(plugin): quote the hook command path for space-containing plugin roots Both plugin hook commands ran `node ${CLAUDE_PLUGIN_ROOT}/hooks/...` unquoted, which breaks whenever the substituted plugin root contains a space — the common case on Windows user profiles. Both Claude Code and Codex substitute the placeholder before shell execution, and Claude Code's plugin docs mandate the double-quoted form in shell-form hooks. No commandWindows entry: Codex source (codex-rs hooks engine) falls back to `command` on Windows with identical placeholder substitution, so an identical-content override would be pure duplication. Verified: space-in-root smoke test (old form exits 1 MODULE_NOT_FOUND, quoted form exits 0), `claude plugin validate` passes, and a local `codex plugin marketplace add` parses the marketplace + plugin cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(setup): pin fail-closed behavior for unreadable/corrupt Codex hooks.json The non-ENOENT suite covered Claude settings.json (EACCES) and Codex config.toml (EACCES) but not the new ~/.codex/hooks.json surface, and the mergeHooksJsonc "is corrupt" branch had zero coverage for either editor. A future refactor dropping the isEnoent rethrow or the parse gate could silently rewrite a user's hooks.json gitnexus-only with no CI tripwire. Two regression tests: EACCES leaves hooks.json byte-identical and reports "Codex hooks: EACCES"; corrupt content is preserved and reported via "Codex hooks: hooks.json is corrupt". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(readme): add the Codex plugin-marketplace install path to the npm README The root README documents the one-step plugin route but the package README (what npmjs.com renders) only showed the setup-CLI path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(setup): rename claudeHook to hookCfg in installClaudeSchemaHooks The local held a codex HookTarget on the codex branch since the installer was parameterized, so the claude-specific name misled. Pure local rename, no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(readme): document Codex SessionStart exclusion, /hooks trust gate, and install-route choice Three behaviors were only recorded in code comments and the PR body: SessionStart is deliberately not registered (Codex reads AGENTS.md natively), setup-installed hooks need one-time /hooks approval in Codex, and the setup CLI and plugin are alternative install routes whose hooks load alongside each other if both are used. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(test): share one logLines helper across setup.test.ts describes The corrupt-hooks.json test inlined the console.log-flattening expression that the non-ENOENT describe already defined locally. Hoist a single file-scope logLines so the two stay in sync. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
247 lines
8.8 KiB
TypeScript
247 lines
8.8 KiB
TypeScript
/**
|
|
* 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'
|
|
| 'codebuddy'
|
|
| 'qoder'
|
|
| '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[];
|
|
/**
|
|
* Older config locations the editor still reads when `file` is absent
|
|
* (CodeBuddy reads only the FIRST existing file in its priority chain).
|
|
* Setup writes into the first existing candidate of [file, ...legacyFiles]
|
|
* so it never shadows a user's servers living in a deprecated file;
|
|
* uninstall sweeps all of them.
|
|
*/
|
|
legacyFiles?: 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, CodeBuddy, Qoder. */
|
|
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'],
|
|
},
|
|
{
|
|
id: 'codebuddy',
|
|
label: 'CodeBuddy',
|
|
// Recommended user-scope path per https://www.codebuddy.ai/docs/cli/mcp;
|
|
// CodeBuddy reads only the first existing file in this priority chain.
|
|
file: path.join(home, '.codebuddy', '.mcp.json'),
|
|
legacyFiles: [
|
|
path.join(home, '.codebuddy', 'mcp.json'), // deprecated
|
|
path.join(home, '.codebuddy.json'), // legacy
|
|
],
|
|
keyPath: ['mcpServers', 'gitnexus'],
|
|
},
|
|
{
|
|
id: 'qoder',
|
|
label: 'Qoder',
|
|
// Qoder's documented user-scope MCP config (https://docs.qoder.com/cli/using-cli);
|
|
// the IDE manages MCP via its Settings UI with no documented file path.
|
|
file: path.join(home, '.qoder.json'),
|
|
keyPath: ['mcpServers', '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') },
|
|
{ id: 'codebuddy', label: 'CodeBuddy', dir: path.join(home, '.codebuddy', 'skills') },
|
|
// Qoder skills live at ~/.qoder/skills/{name}/SKILL.md
|
|
// (https://docs.qoder.com/extensions/skills).
|
|
{ id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', '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: 'codex',
|
|
label: 'Codex',
|
|
// Codex hooks use Claude Code's exact {hooks: {Event: [...]}} JSON shape
|
|
// and hookSpecificOutput response contract, in a dedicated hooks.json
|
|
// (https://developers.openai.com/codex/hooks).
|
|
settingsFile: path.join(home, '.codex', 'hooks.json'),
|
|
events: ['PreToolUse', 'PostToolUse'],
|
|
needle: 'gitnexus-hook',
|
|
scriptDir: path.join(home, '.codex', '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;
|
|
}
|
|
|
|
/**
|
|
* True when err is a Node fs error with code ENOENT (file/dir absent).
|
|
* Shared by setup and uninstall: both must swallow ONLY absence when reading
|
|
* editor configs — any other read/stat failure (EACCES, EIO) is surfaced so an
|
|
* unreadable config is never treated as empty and rewritten gitnexus-only.
|
|
*/
|
|
export function isEnoent(err: unknown): boolean {
|
|
return (err as NodeJS.ErrnoException)?.code === 'ENOENT';
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|