Skip to content
This repository was archived by the owner on May 25, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
63 changes: 63 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
}
215 changes: 214 additions & 1 deletion setup/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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');
Expand All @@ -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); });
Expand Down
Loading