diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json new file mode 100644 index 0000000..3b11ea0 --- /dev/null +++ b/.codex-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "gradata", + "version": "0.1.0", + "description": "AI that learns your judgment - auto-captures corrections and injects graduated rules", + "author": { + "name": "Gradata", + "email": "oliver@gradata.ai" + }, + "hooks": "./hooks/hooks.json", + "skills": "./skills" +} diff --git a/README.md b/README.md index 8dc557d..297a01e 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,13 @@ node ~/.gradata/plugin/setup/doctor.js - **Claude Code** — installer also creates `~/.claude/plugins/gradata` symlinking the checkout, so `/gradata` slash-commands work out of the box. -- **Codex / OpenCode / Hermes** — pick up the Gradata block from `AGENTS.md` +- **Codex** — installer adds a managed Gradata hook block to + `~/.codex/config.toml` so session lifecycle events fire graduation and + AGENTS.md maintenance hooks. +- **Cursor** — run `gradata install --agent cursor` (or + `node ~/.gradata/plugin/setup/install.js --agent cursor`) to create/merge + `~/.cursor/hooks.json` with Gradata lifecycle hook commands. +- **OpenCode / Hermes** — pick up the Gradata block from `AGENTS.md` automatically. The `gradata-quickstart` skill provides the full reference; the doctor command is the universal health check: `node ~/.gradata/plugin/setup/doctor.js`. diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..7449778 --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,63 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/session-start.js" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/user-prompt.js" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/post-edit.js" + }, + { + "type": "command", + "command": "node hooks/post-tool-extended.js" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/pre-compact.js" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "node hooks/session-stop.js" + } + ] + } + ] + } +} diff --git a/setup/install.js b/setup/install.js index 4aeed4a..655d2d7 100644 --- a/setup/install.js +++ b/setup/install.js @@ -22,6 +22,11 @@ function flagValue(name) { const AUTO = hasFlag('--auto'); const PATCH_AGENTS_MD_EXPLICIT = hasFlag('--patch-agents-md'); +const INSTALL_AGENT = (flagValue('--agent') || '').trim().toLowerCase(); +const CODEX_CONFIG_DIR = path.join(HOME, '.codex'); +const CODEX_CONFIG_PATH = path.join(CODEX_CONFIG_DIR, 'config.toml'); +const CURSOR_CONFIG_DIR = path.join(HOME, '.cursor'); +const CURSOR_HOOKS_PATH = path.join(CURSOR_CONFIG_DIR, 'hooks.json'); function tryPython(cmd) { try { @@ -204,6 +209,171 @@ function resolveAgentsMdTarget() { return homeCandidate; } +// --- Codex hooks patching --------------------------------------------------- + +const CODEX_BEGIN_MARKER = '# BEGIN GRADATA CODEX HOOKS'; +const CODEX_END_MARKER = '# END GRADATA CODEX HOOKS'; + +function buildCodexHookBlock(pluginRoot) { + const p = pluginRoot.replace(/\\/g, '/').replace(/"/g, '\\"'); + return [ + CODEX_BEGIN_MARKER, + '# Managed by Gradata installer. Re-run installer to update paths.', + '[features]', + 'hooks = true', + '', + '[hooks]', + '', + '[[hooks.SessionStart]]', + 'matcher = "*"', + '[[hooks.SessionStart.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/session-start.js\\""`, + '', + '[[hooks.UserPromptSubmit]]', + 'matcher = "*"', + '[[hooks.UserPromptSubmit.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/user-prompt.js\\""`, + '', + '[[hooks.PostToolUse]]', + 'matcher = "*"', + '[[hooks.PostToolUse.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/post-edit.js\\""`, + '[[hooks.PostToolUse.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/post-tool-extended.js\\""`, + '', + '[[hooks.PreCompact]]', + 'matcher = "*"', + '[[hooks.PreCompact.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/pre-compact.js\\""`, + '', + '[[hooks.Stop]]', + 'matcher = "*"', + '[[hooks.Stop.hooks]]', + 'type = "command"', + `command = "node \\"${p}/hooks/session-stop.js\\""`, + CODEX_END_MARKER, + '', + ].join('\n'); +} + +function patchCodexConfig(pluginRoot) { + const block = buildCodexHookBlock(pluginRoot); + fs.mkdirSync(CODEX_CONFIG_DIR, { recursive: true }); + if (!fs.existsSync(CODEX_CONFIG_PATH)) { + fs.writeFileSync(CODEX_CONFIG_PATH, block, 'utf8'); + return { action: 'created', path: CODEX_CONFIG_PATH }; + } + + const original = fs.readFileSync(CODEX_CONFIG_PATH, 'utf8'); + const begin = original.indexOf(CODEX_BEGIN_MARKER); + const end = original.indexOf(CODEX_END_MARKER); + + if (begin === -1 && end === -1) { + let out = original; + if (!out.endsWith('\n')) out += '\n'; + if (!out.endsWith('\n\n')) out += '\n'; + out += block; + fs.writeFileSync(CODEX_CONFIG_PATH, out, 'utf8'); + return { action: 'appended', path: CODEX_CONFIG_PATH }; + } + + if (begin !== -1 && end !== -1 && begin < end) { + const before = original.slice(0, begin).replace(/\s*$/, ''); + const after = original.slice(end + CODEX_END_MARKER.length).replace(/^\s*/, ''); + const body = block.trimEnd(); + let out = ''; + if (before) out += `${before}\n\n`; + out += body; + if (after) out += `\n\n${after}`; + out += '\n'; + if (out === original) return { action: 'unchanged', path: CODEX_CONFIG_PATH }; + fs.writeFileSync(CODEX_CONFIG_PATH, out, 'utf8'); + return { action: 'replaced', path: CODEX_CONFIG_PATH }; + } + + return { action: 'refused', path: CODEX_CONFIG_PATH }; +} + +// --- Cursor hooks patching -------------------------------------------------- + +function buildCursorHookCommands(pluginRoot) { + const root = pluginRoot.replace(/\\/g, '/').replace(/"/g, '\\"'); + return { + beforeSubmitPrompt: [ + { command: `node "${root}/hooks/user-prompt.js"` }, + ], + afterFileEdit: [ + { command: `node "${root}/hooks/post-edit.js"` }, + ], + afterShellExecution: [ + { command: `node "${root}/hooks/post-tool-extended.js"` }, + ], + stop: [ + { command: `node "${root}/hooks/session-stop.js"` }, + ], + sessionStart: [ + { command: `node "${root}/hooks/session-start.js"` }, + ], + }; +} + +function normalizeCursorEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + if (typeof entry.command !== 'string' || entry.command.trim() === '') return null; + return { command: entry.command.trim() }; +} + +function patchCursorHooks(pluginRoot) { + const desired = buildCursorHookCommands(pluginRoot); + fs.mkdirSync(CURSOR_CONFIG_DIR, { recursive: true }); + + let originalObj = { version: 1, hooks: {} }; + let existed = false; + if (fs.existsSync(CURSOR_HOOKS_PATH)) { + existed = true; + try { + const parsed = JSON.parse(fs.readFileSync(CURSOR_HOOKS_PATH, 'utf8')); + if (parsed && typeof parsed === 'object') originalObj = parsed; + } catch { + return { action: 'refused', path: CURSOR_HOOKS_PATH }; + } + } + + const out = { + ...originalObj, + version: typeof originalObj.version === 'number' ? originalObj.version : 1, + hooks: (originalObj.hooks && typeof originalObj.hooks === 'object') ? { ...originalObj.hooks } : {}, + }; + + let changed = false; + for (const [eventName, desiredEntries] of Object.entries(desired)) { + const currentRaw = Array.isArray(out.hooks[eventName]) ? out.hooks[eventName] : []; + const current = currentRaw.map(normalizeCursorEntry).filter(Boolean); + const seen = new Set(current.map(e => e.command)); + for (const want of desiredEntries) { + if (!seen.has(want.command)) { + current.push(want); + seen.add(want.command); + changed = true; + } + } + out.hooks[eventName] = current; + } + + if (!existed) { + fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(out, null, 2) + '\n', 'utf8'); + return { action: 'created', path: CURSOR_HOOKS_PATH }; + } + if (!changed) return { action: 'unchanged', path: CURSOR_HOOKS_PATH }; + fs.writeFileSync(CURSOR_HOOKS_PATH, JSON.stringify(out, null, 2) + '\n', 'utf8'); + return { action: 'merged', path: CURSOR_HOOKS_PATH }; +} + // --- Main ------------------------------------------------------------------- async function main() { @@ -277,6 +447,37 @@ async function main() { console.log(`AGENTS.md patch skipped: ${e.message}`); } + // Wire host runtime hooks. + const pluginRoot = path.join(GRADATA_HOME, 'plugin'); + const installCursor = INSTALL_AGENT === 'cursor'; + const installCodex = INSTALL_AGENT === '' || INSTALL_AGENT === 'codex'; + + if (installCodex) { + try { + const codexPatch = patchCodexConfig(pluginRoot); + if (codexPatch.action === 'refused') { + console.log(`Codex hooks patch refused: ${codexPatch.path} has ambiguous Gradata markers`); + } else { + console.log(`Codex hooks ${codexPatch.action}: ${codexPatch.path}`); + } + } catch (e) { + console.log(`Codex hooks patch skipped: ${e.message}`); + } + } + + if (installCursor) { + try { + const cursorPatch = patchCursorHooks(pluginRoot); + if (cursorPatch.action === 'refused') { + console.log(`Cursor hooks patch refused: ${cursorPatch.path} is not valid JSON`); + } else { + console.log(`Cursor hooks ${cursorPatch.action}: ${cursorPatch.path}`); + } + } catch (e) { + console.log(`Cursor hooks patch skipped: ${e.message}`); + } + } + console.log('\nReady.'); if (AUTO) { const doctor = path.join(GRADATA_HOME, 'plugin', 'setup', 'doctor.js'); @@ -296,7 +497,19 @@ async function main() { } } -module.exports = { patchAgentsMd, loadTemplate, scanMarkers, BEGIN_MARKER, END_MARKER }; +module.exports = { + patchAgentsMd, + loadTemplate, + scanMarkers, + BEGIN_MARKER, + END_MARKER, + patchCodexConfig, + buildCodexHookBlock, + CODEX_BEGIN_MARKER, + CODEX_END_MARKER, + patchCursorHooks, + buildCursorHookCommands, +}; if (require.main === module) { main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/tests/install-idempotent.test.js b/tests/install-idempotent.test.js index 38ac2d9..af17dc0 100644 --- a/tests/install-idempotent.test.js +++ b/tests/install-idempotent.test.js @@ -172,3 +172,110 @@ test('doctor: resolveDaemonPort default is 7342 when nothing configured', () => delete require.cache[require.resolve('../setup/doctor.js')]; } }); + +test('codex config: absent -> created with managed markers', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-codex-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCodexConfig, CODEX_BEGIN_MARKER, CODEX_END_MARKER } = require('../setup/install.js'); + try { + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const r = patchCodexConfig(pluginRoot); + assert.strictEqual(r.action, 'created'); + const cfg = fs.readFileSync(path.join(home, '.codex', 'config.toml'), 'utf8'); + assert.ok(cfg.includes(CODEX_BEGIN_MARKER)); + assert.ok(cfg.includes(CODEX_END_MARKER)); + assert.ok(cfg.includes('hooks = true')); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +}); + +test('codex config: existing content preserved and gradata block appended idempotently', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-codex-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCodexConfig } = require('../setup/install.js'); + try { + const codexDir = path.join(home, '.codex'); + fs.mkdirSync(codexDir, { recursive: true }); + const cfgPath = path.join(codexDir, 'config.toml'); + const original = 'personality = "pragmatic"\n'; + fs.writeFileSync(cfgPath, original, 'utf8'); + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const a = patchCodexConfig(pluginRoot); + const first = fs.readFileSync(cfgPath, 'utf8'); + const b = patchCodexConfig(pluginRoot); + const second = fs.readFileSync(cfgPath, 'utf8'); + assert.strictEqual(a.action, 'appended'); + assert.ok(first.startsWith(original)); + assert.strictEqual(b.action, 'unchanged'); + assert.strictEqual(first, second); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +}); + +test('cursor hooks: absent -> created with gradata hook commands', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-cursor-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCursorHooks } = require('../setup/install.js'); + try { + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const r = patchCursorHooks(pluginRoot); + assert.strictEqual(r.action, 'created'); + const hooksPath = path.join(home, '.cursor', 'hooks.json'); + const cfg = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + assert.ok(cfg.hooks); + assert.ok(Array.isArray(cfg.hooks.beforeSubmitPrompt)); + assert.ok(Array.isArray(cfg.hooks.afterFileEdit)); + assert.ok(Array.isArray(cfg.hooks.afterShellExecution)); + assert.ok(Array.isArray(cfg.hooks.stop)); + assert.ok(Array.isArray(cfg.hooks.sessionStart)); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +}); + +test('cursor hooks: existing entries preserved and gradata commands merged once', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gradata-cursor-home-')); + const prevHome = process.env.HOME; + process.env.HOME = home; + delete require.cache[require.resolve('../setup/install.js')]; + const { patchCursorHooks } = require('../setup/install.js'); + try { + const cursorDir = path.join(home, '.cursor'); + fs.mkdirSync(cursorDir, { recursive: true }); + const hooksPath = path.join(cursorDir, 'hooks.json'); + fs.writeFileSync( + hooksPath, + JSON.stringify({ + version: 1, + hooks: { + beforeSubmitPrompt: [{ command: 'echo pre-existing' }], + }, + }, null, 2) + '\n', + 'utf8' + ); + const pluginRoot = path.join(home, '.gradata', 'plugin'); + const a = patchCursorHooks(pluginRoot); + const b = patchCursorHooks(pluginRoot); + const cfg = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + assert.strictEqual(a.action, 'merged'); + assert.strictEqual(b.action, 'unchanged'); + assert.ok(cfg.hooks.beforeSubmitPrompt.some(h => h.command === 'echo pre-existing')); + const gradataPromptHooks = cfg.hooks.beforeSubmitPrompt + .filter(h => typeof h.command === 'string' && h.command.includes('/hooks/user-prompt.js')); + assert.strictEqual(gradataPromptHooks.length, 1, 'gradata command should not duplicate'); + } finally { + if (prevHome === undefined) delete process.env.HOME; else process.env.HOME = prevHome; + delete require.cache[require.resolve('../setup/install.js')]; + } +});