gramatr 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr uninstaller — reverses install, restores configs to pre-gramatr state.
4
+ *
5
+ * Usage:
6
+ * bun uninstall.ts # Interactive — confirms each step
7
+ * bun uninstall.ts --yes # Non-interactive — uninstall everything
8
+ * bun uninstall.ts --keep-auth # Keep ~/.gmtr.json (preserve login)
9
+ * npx tsx uninstall.ts # Without bun
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { createInterface } from 'readline';
15
+
16
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
17
+ const args = process.argv.slice(2);
18
+ const YES = args.includes('--yes') || args.includes('-y');
19
+ const KEEP_AUTH = args.includes('--keep-auth');
20
+
21
+ function log(msg: string): void { process.stdout.write(`${msg}\n`); }
22
+
23
+ async function confirm(msg: string): Promise<boolean> {
24
+ if (YES) return true;
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ return new Promise((resolve) => {
27
+ rl.question(` ${msg} [Y/n]: `, (answer) => {
28
+ rl.close();
29
+ resolve(!answer || answer.toLowerCase() === 'y');
30
+ });
31
+ });
32
+ }
33
+
34
+ function findLatestBackup(filePath: string): string | null {
35
+ const dir = dirname(filePath);
36
+ const base = filePath.split('/').pop() || '';
37
+ try {
38
+ const files = readdirSync(dir)
39
+ .filter(f => f.startsWith(`${base}.backup-`))
40
+ .sort()
41
+ .reverse();
42
+ return files.length > 0 ? join(dir, files[0]) : null;
43
+ } catch { return null; }
44
+ }
45
+
46
+ function removeGramatrFromJson(filePath: string, keys: string[]): boolean {
47
+ try {
48
+ if (!existsSync(filePath)) return false;
49
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
50
+ let changed = false;
51
+ for (const key of keys) {
52
+ if (key in data) {
53
+ delete data[key];
54
+ changed = true;
55
+ }
56
+ // Handle nested keys like "env.GMTR_TOKEN"
57
+ if (key.includes('.')) {
58
+ const [parent, child] = key.split('.');
59
+ if (data[parent] && child in data[parent]) {
60
+ delete data[parent][child];
61
+ changed = true;
62
+ }
63
+ }
64
+ }
65
+ // Remove gramatr from mcpServers
66
+ if (data.mcpServers?.gramatr) {
67
+ delete data.mcpServers.gramatr;
68
+ changed = true;
69
+ }
70
+ if (changed) {
71
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
72
+ }
73
+ return changed;
74
+ } catch { return false; }
75
+ }
76
+
77
+ function dirSize(dir: string): string {
78
+ try {
79
+ let total = 0;
80
+ const walk = (d: string) => {
81
+ for (const f of readdirSync(d)) {
82
+ const p = join(d, f);
83
+ const s = statSync(p);
84
+ if (s.isDirectory()) walk(p); else total += s.size;
85
+ }
86
+ };
87
+ walk(dir);
88
+ if (total > 1048576) return `${(total / 1048576).toFixed(1)}MB`;
89
+ if (total > 1024) return `${(total / 1024).toFixed(0)}KB`;
90
+ return `${total}B`;
91
+ } catch { return '?'; }
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ log('');
96
+ log(' gramatr uninstaller');
97
+ log(' ===================');
98
+ log('');
99
+
100
+ const gmtrClient = join(HOME, 'gmtr-client');
101
+ const gmtrJson = join(HOME, '.gmtr.json');
102
+ const gmtrDotDir = join(HOME, '.gmtr');
103
+ const claudeSettings = join(HOME, '.claude', 'settings.json');
104
+ const claudeJson = join(HOME, '.claude.json');
105
+ const claudeSkills = join(HOME, '.claude', 'skills');
106
+ const claudeAgents = join(HOME, '.claude', 'agents');
107
+ const claudeCommands = join(HOME, '.claude', 'commands');
108
+ const codexHooks = join(HOME, '.codex', 'hooks.json');
109
+ const codexAgents = join(HOME, '.codex', 'AGENTS.md');
110
+ const geminiExt = join(HOME, '.gemini', 'extensions', 'gramatr');
111
+
112
+ // Detect what's installed
113
+ const installed: string[] = [];
114
+ if (existsSync(gmtrClient)) installed.push(`~/gmtr-client (${dirSize(gmtrClient)})`);
115
+ if (existsSync(gmtrDotDir)) installed.push(`~/.gmtr/ (${dirSize(gmtrDotDir)})`);
116
+ if (existsSync(gmtrJson)) installed.push('~/.gmtr.json');
117
+ if (existsSync(claudeSettings)) installed.push('~/.claude/settings.json (hooks)');
118
+ if (existsSync(claudeJson)) installed.push('~/.claude.json (MCP server)');
119
+ if (existsSync(codexHooks)) installed.push('~/.codex/hooks.json');
120
+ if (existsSync(geminiExt)) installed.push(`~/.gemini/extensions/gramatr (${dirSize(geminiExt)})`);
121
+
122
+ if (installed.length === 0) {
123
+ log(' Nothing to uninstall — gramatr is not installed.');
124
+ log('');
125
+ return;
126
+ }
127
+
128
+ log(' Found:');
129
+ for (const item of installed) log(` - ${item}`);
130
+ log('');
131
+
132
+ if (!await confirm('Proceed with uninstall?')) {
133
+ log(' Cancelled.');
134
+ return;
135
+ }
136
+
137
+ log('');
138
+
139
+ // 1. Remove ~/gmtr-client (hooks, bin, skills, agents, tools)
140
+ if (existsSync(gmtrClient)) {
141
+ rmSync(gmtrClient, { recursive: true, force: true });
142
+ log(' OK Removed ~/gmtr-client');
143
+ }
144
+
145
+ // 2. Remove ~/.gmtr/ (bun binary, PATH additions)
146
+ if (existsSync(gmtrDotDir)) {
147
+ rmSync(gmtrDotDir, { recursive: true, force: true });
148
+ log(' OK Removed ~/.gmtr/');
149
+ }
150
+
151
+ // 3. Remove ~/.gmtr.json (auth token) — unless --keep-auth
152
+ if (existsSync(gmtrJson)) {
153
+ if (KEEP_AUTH) {
154
+ log(' -- Kept ~/.gmtr.json (--keep-auth)');
155
+ } else {
156
+ rmSync(gmtrJson);
157
+ log(' OK Removed ~/.gmtr.json');
158
+ }
159
+ }
160
+
161
+ // 4. Restore ~/.claude/settings.json from backup or clean gramatr hooks
162
+ if (existsSync(claudeSettings)) {
163
+ const backup = findLatestBackup(claudeSettings);
164
+ if (backup && await confirm(`Restore settings.json from backup (${backup.split('/').pop()})?`)) {
165
+ const backupContent = readFileSync(backup, 'utf8');
166
+ writeFileSync(claudeSettings, backupContent);
167
+ log(` OK Restored ~/.claude/settings.json from ${backup.split('/').pop()}`);
168
+ } else {
169
+ // Remove gramatr hooks from settings
170
+ try {
171
+ const settings = JSON.parse(readFileSync(claudeSettings, 'utf8'));
172
+ if (settings.hooks) {
173
+ // Remove hook entries that reference gmtr-client
174
+ for (const [event, hooks] of Object.entries(settings.hooks)) {
175
+ if (Array.isArray(hooks)) {
176
+ settings.hooks[event] = (hooks as any[]).filter(
177
+ (h: any) => !h.command?.includes('gmtr-client') && !h.command?.includes('gramatr')
178
+ );
179
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
180
+ }
181
+ }
182
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
183
+ }
184
+ if (settings.statusLine?.command?.includes('gmtr-client')) {
185
+ delete settings.statusLine;
186
+ }
187
+ writeFileSync(claudeSettings, JSON.stringify(settings, null, 2) + '\n');
188
+ log(' OK Removed gramatr hooks from ~/.claude/settings.json');
189
+ } catch {
190
+ log(' X Could not clean settings.json — restore from backup manually');
191
+ }
192
+ }
193
+ }
194
+
195
+ // 5. Remove gramatr from ~/.claude.json (MCP server + env vars)
196
+ if (existsSync(claudeJson)) {
197
+ const cleaned = removeGramatrFromJson(claudeJson, ['env.GMTR_TOKEN', 'env.AIOS_MCP_TOKEN']);
198
+ if (cleaned) log(' OK Removed gramatr MCP server + env vars from ~/.claude.json');
199
+ }
200
+
201
+ // 6. Remove gramatr skills from ~/.claude/skills/
202
+ if (existsSync(claudeSkills)) {
203
+ try {
204
+ const skillDirs = readdirSync(claudeSkills);
205
+ let removed = 0;
206
+ for (const dir of skillDirs) {
207
+ const skillPath = join(claudeSkills, dir);
208
+ // Check if it's a gramatr-installed skill (has gramatr marker or matches known patterns)
209
+ if (existsSync(join(skillPath, '.gramatr')) || existsSync(join(skillPath, 'README.md'))) {
210
+ rmSync(skillPath, { recursive: true, force: true });
211
+ removed++;
212
+ }
213
+ }
214
+ if (removed > 0) log(` OK Removed ${removed} skill directories from ~/.claude/skills/`);
215
+ } catch { /* non-critical */ }
216
+ }
217
+
218
+ // 7. Remove gramatr agents from ~/.claude/agents/
219
+ if (existsSync(claudeAgents)) {
220
+ try {
221
+ rmSync(claudeAgents, { recursive: true, force: true });
222
+ log(' OK Removed ~/.claude/agents/');
223
+ } catch { /* non-critical */ }
224
+ }
225
+
226
+ // 8. Remove gramatr commands from ~/.claude/commands/
227
+ if (existsSync(claudeCommands)) {
228
+ try {
229
+ const cmds = readdirSync(claudeCommands).filter(f => f.startsWith('gmtr-') || f.startsWith('gramatr-'));
230
+ for (const cmd of cmds) {
231
+ rmSync(join(claudeCommands, cmd), { recursive: true, force: true });
232
+ }
233
+ if (cmds.length > 0) log(` OK Removed ${cmds.length} gramatr commands from ~/.claude/commands/`);
234
+ } catch { /* non-critical */ }
235
+ }
236
+
237
+ // 9. Clean Codex config
238
+ if (existsSync(codexHooks)) {
239
+ try {
240
+ const hooks = JSON.parse(readFileSync(codexHooks, 'utf8'));
241
+ if (hooks.hooks) {
242
+ for (const [event, eventHooks] of Object.entries(hooks.hooks)) {
243
+ if (Array.isArray(eventHooks)) {
244
+ hooks.hooks[event] = (eventHooks as any[]).filter(
245
+ (h: any) => !h.command?.includes('gmtr-client') && !h.command?.includes('gramatr')
246
+ );
247
+ if (hooks.hooks[event].length === 0) delete hooks.hooks[event];
248
+ }
249
+ }
250
+ writeFileSync(codexHooks, JSON.stringify(hooks, null, 2) + '\n');
251
+ log(' OK Cleaned gramatr hooks from ~/.codex/hooks.json');
252
+ }
253
+ } catch { /* non-critical */ }
254
+ }
255
+ if (existsSync(codexAgents)) {
256
+ // Remove managed block from AGENTS.md
257
+ try {
258
+ let content = readFileSync(codexAgents, 'utf8');
259
+ const startMarker = '<!-- GMTR-CODEX-START -->';
260
+ const endMarker = '<!-- GMTR-CODEX-END -->';
261
+ const startIdx = content.indexOf(startMarker);
262
+ const endIdx = content.indexOf(endMarker);
263
+ if (startIdx !== -1 && endIdx !== -1) {
264
+ content = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length);
265
+ writeFileSync(codexAgents, content.trim() + '\n');
266
+ log(' OK Removed gramatr block from ~/.codex/AGENTS.md');
267
+ }
268
+ } catch { /* non-critical */ }
269
+ }
270
+
271
+ // 10. Remove Gemini extension
272
+ if (existsSync(geminiExt)) {
273
+ rmSync(geminiExt, { recursive: true, force: true });
274
+ log(' OK Removed ~/.gemini/extensions/gramatr');
275
+ }
276
+
277
+ log('');
278
+ log(' Uninstall complete.');
279
+ log(' Restart Claude Code / Codex / Gemini CLI to apply changes.');
280
+ if (KEEP_AUTH) {
281
+ log(' Auth token preserved in ~/.gmtr.json (use --token with next install).');
282
+ }
283
+ log('');
284
+ }
285
+
286
+ main().catch((err) => {
287
+ log(` Error: ${err.message}`);
288
+ process.exit(1);
289
+ });
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * version-sync — stamps the current package.json version into files that display it.
4
+ *
5
+ * Called automatically by:
6
+ * npm run version:patch → 0.3.0 → 0.3.1
7
+ * npm run version:minor → 0.3.0 → 0.4.0
8
+ * npm run version:major → 0.3.0 → 1.0.0
9
+ * npm run prepublishOnly → ensures sync before publish
10
+ */
11
+
12
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { fileURLToPath } from 'url';
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const clientDir = dirname(__dirname);
18
+ const pkg = JSON.parse(readFileSync(join(clientDir, 'package.json'), 'utf8'));
19
+ const version = pkg.version;
20
+
21
+ console.log(`gramatr version: ${version}`);
22
+
23
+ // 1. Stamp into settings.json template (install.ts writes this)
24
+ // No file to patch — install.ts reads package.json at runtime.
25
+
26
+ // 2. Stamp into CLAUDE.md if it has a version marker
27
+ const claudeMd = join(clientDir, 'CLAUDE.md');
28
+ if (existsSync(claudeMd)) {
29
+ let content = readFileSync(claudeMd, 'utf8');
30
+ const versionPattern = /gramatr\s+v[\d.]+/g;
31
+ const newContent = content.replace(versionPattern, `gramatr v${version}`);
32
+ if (newContent !== content) {
33
+ writeFileSync(claudeMd, newContent);
34
+ console.log(` OK Stamped version in CLAUDE.md`);
35
+ }
36
+ }
37
+
38
+ // 3. Write a version.ts module that other TS files can import
39
+ const versionTs = join(clientDir, 'core', 'version.ts');
40
+ writeFileSync(versionTs, `/** Auto-generated by version-sync.ts — do not edit */\nexport const VERSION = '${version}';\n`);
41
+ console.log(` OK Wrote core/version.ts`);
42
+
43
+ // 4. Log next steps
44
+ console.log('');
45
+ console.log(` To publish: npm publish --access public`);
46
+ console.log(` To tag: git tag v${version} && git push origin v${version}`);
@@ -0,0 +1,28 @@
1
+ # Codex Integration
2
+
3
+ This directory contains the first-pass Codex client scaffolding for gramatr.
4
+
5
+ Current scope:
6
+ - TypeScript hook implementations for `UserPromptSubmit` and `SessionStart`
7
+ - pure formatting utilities with Vitest coverage
8
+ - a repo-local `.codex/hooks.json` template
9
+ - a fallback `AGENTS.md` template for Codex sessions
10
+
11
+ Runtime approach:
12
+ - use Codex hooks for interception and session restore
13
+ - keep the hook code in TypeScript
14
+ - keep formatting/decision logic in pure utility modules so coverage is meaningful
15
+
16
+ Packaging direction:
17
+ - hooks remain the runtime interception layer
18
+ - plugin packaging remains the distribution/update layer
19
+
20
+ Install locally with:
21
+ - `pnpm --filter @aios-v2/client install-codex`
22
+
23
+ The installer:
24
+ - syncs this Codex runtime into `~/gmtr-client/codex`
25
+ - syncs the shared `gmtr-hook-utils.ts` dependency into `~/gmtr-client/hooks/lib`
26
+ - merges `~/.codex/hooks.json`
27
+ - enables `codex_hooks` in `~/.codex/config.toml`
28
+ - upserts a managed gramatr block in `~/.codex/AGENTS.md`
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ getGitContext,
5
+ readHookInput,
6
+ } from '../../hooks/lib/gmtr-hook-utils.ts';
7
+ import {
8
+ loadProjectHandoff,
9
+ normalizeSessionStartResponse,
10
+ persistSessionRegistration,
11
+ prepareProjectSessionState,
12
+ startRemoteSession,
13
+ } from '../../core/session.ts';
14
+ import {
15
+ buildCodexHookOutput,
16
+ buildSessionStartAdditionalContext,
17
+ type HandoffResponse,
18
+ type SessionStartResponse,
19
+ } from '../lib/codex-hook-utils.ts';
20
+
21
+ async function main(): Promise<void> {
22
+ try {
23
+ const input = await readHookInput();
24
+ const git = getGitContext();
25
+ if (!git) return;
26
+
27
+ const transcriptPath = input.transcript_path || '';
28
+ const sessionId = input.session_id || 'unknown';
29
+ const prepared = prepareProjectSessionState({
30
+ git,
31
+ sessionId,
32
+ transcriptPath,
33
+ });
34
+
35
+ const sessionStart = (await startRemoteSession({
36
+ clientType: 'codex',
37
+ sessionId: input.session_id,
38
+ projectId: prepared.projectId,
39
+ projectName: git.projectName,
40
+ gitRemote: git.remote,
41
+ directory: git.root,
42
+ })) as SessionStartResponse | null;
43
+
44
+ if (sessionStart) {
45
+ persistSessionRegistration(git.root, sessionStart);
46
+ }
47
+
48
+ const handoff = (await loadProjectHandoff(prepared.projectId)) as HandoffResponse | null;
49
+ const normalizedSessionStart = sessionStart
50
+ ? {
51
+ ...sessionStart,
52
+ interaction_id: normalizeSessionStartResponse(sessionStart).interactionId || undefined,
53
+ }
54
+ : null;
55
+
56
+ const additionalContext = buildSessionStartAdditionalContext(
57
+ prepared.projectId,
58
+ normalizedSessionStart,
59
+ handoff,
60
+ );
61
+ const output = buildCodexHookOutput(
62
+ 'SessionStart',
63
+ additionalContext,
64
+ 'gramatr session context loaded',
65
+ );
66
+
67
+ process.stdout.write(JSON.stringify(output));
68
+ } catch {
69
+ // Never block startup if the hook fails.
70
+ }
71
+ }
72
+
73
+ void main();
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ getGitContext,
5
+ readHookInput,
6
+ } from '../../hooks/lib/gmtr-hook-utils.ts';
7
+ import { parseTranscript } from '../../hooks/lib/transcript-parser.ts';
8
+ import { submitPendingClassificationFeedback } from '../../hooks/lib/classification-feedback.ts';
9
+
10
+ async function main(): Promise<void> {
11
+ try {
12
+ const input = await readHookInput();
13
+ if (!input.transcript_path || !input.session_id) return;
14
+
15
+ const git = getGitContext();
16
+ if (!git) return;
17
+
18
+ const parsed = parseTranscript(input.transcript_path);
19
+ if (parsed.responseState !== 'completed') return;
20
+
21
+ await submitPendingClassificationFeedback({
22
+ rootDir: git.root,
23
+ sessionId: input.session_id,
24
+ originalPrompt: parsed.lastUserPrompt,
25
+ clientType: 'codex',
26
+ agentName: 'Codex',
27
+ downstreamProvider: 'openai',
28
+ });
29
+ } catch {
30
+ // Never block completion if the hook fails.
31
+ }
32
+ }
33
+
34
+ void main();
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ deriveProjectId,
5
+ getGitContext,
6
+ readHookInput,
7
+ } from '../../hooks/lib/gmtr-hook-utils.ts';
8
+ import {
9
+ describeRoutingFailure,
10
+ persistClassificationResult,
11
+ routePrompt,
12
+ shouldSkipPromptRouting,
13
+ } from '../../core/routing.ts';
14
+ import {
15
+ buildHookFailureAdditionalContext,
16
+ buildCodexHookOutput,
17
+ buildUserPromptAdditionalContext,
18
+ type RouteResponse,
19
+ } from '../lib/codex-hook-utils.ts';
20
+
21
+ async function main(): Promise<void> {
22
+ try {
23
+ const input = await readHookInput();
24
+ const prompt = (input.prompt || input.message || '').trim();
25
+
26
+ if (shouldSkipPromptRouting(prompt)) {
27
+ return;
28
+ }
29
+
30
+ const git = getGitContext();
31
+ const projectId = git ? deriveProjectId(git.remote, git.projectName) : undefined;
32
+ const result = await routePrompt({
33
+ prompt,
34
+ projectId,
35
+ sessionId: input.session_id,
36
+ timeoutMs: 15000,
37
+ });
38
+ const route = result.route as RouteResponse | null;
39
+
40
+ if (!route) {
41
+ if (result.error) {
42
+ const failure = describeRoutingFailure(result.error);
43
+ const output = buildCodexHookOutput(
44
+ 'UserPromptSubmit',
45
+ buildHookFailureAdditionalContext(failure),
46
+ 'gramatr request routing unavailable',
47
+ );
48
+ process.stdout.write(JSON.stringify(output));
49
+ }
50
+ return;
51
+ }
52
+
53
+ const additionalContext = buildUserPromptAdditionalContext(route);
54
+ if (git) {
55
+ persistClassificationResult({
56
+ rootDir: git.root,
57
+ prompt,
58
+ route,
59
+ downstreamModel: null,
60
+ clientType: 'codex',
61
+ agentName: 'Codex',
62
+ });
63
+ }
64
+ const output = buildCodexHookOutput(
65
+ 'UserPromptSubmit',
66
+ additionalContext,
67
+ 'gramatr request routing active',
68
+ );
69
+
70
+ process.stdout.write(JSON.stringify(output));
71
+ } catch {
72
+ // Never block the user prompt if the hook fails.
73
+ }
74
+ }
75
+
76
+ void main();
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, copyFileSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import {
7
+ buildManagedHooks,
8
+ ensureCodexHooksFeature,
9
+ mergeHooksFile,
10
+ upsertManagedBlock,
11
+ } from './lib/codex-install-utils.ts';
12
+
13
+ const START_MARKER = '<!-- GMTR-CODEX-START -->';
14
+ const END_MARKER = '<!-- GMTR-CODEX-END -->';
15
+
16
+ function log(message: string): void {
17
+ process.stdout.write(`${message}\n`);
18
+ }
19
+
20
+ function ensureDir(path: string): void {
21
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
22
+ }
23
+
24
+ function copyRecursive(source: string, target: string): void {
25
+ const stats = statSync(source);
26
+
27
+ if (stats.isDirectory()) {
28
+ ensureDir(target);
29
+ for (const entry of readdirSync(source)) {
30
+ copyRecursive(join(source, entry), join(target, entry));
31
+ }
32
+ return;
33
+ }
34
+
35
+ ensureDir(dirname(target));
36
+ copyFileSync(source, target);
37
+ }
38
+
39
+ function readJsonFile<T>(path: string, fallback: T): T {
40
+ if (!existsSync(path)) return fallback;
41
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
42
+ }
43
+
44
+ function main(): void {
45
+ const home = process.env.HOME || process.env.USERPROFILE;
46
+ if (!home) {
47
+ throw new Error('HOME is not set');
48
+ }
49
+
50
+ const gmtrDir = process.env.GMTR_DIR || join(home, 'gmtr-client');
51
+ const codexHome = join(home, '.codex');
52
+ const hooksPath = join(codexHome, 'hooks.json');
53
+ const configPath = join(codexHome, 'config.toml');
54
+ const agentsPath = join(codexHome, 'AGENTS.md');
55
+
56
+ const currentFile = fileURLToPath(import.meta.url);
57
+ const codexSourceDir = dirname(currentFile);
58
+ const clientSourceDir = dirname(codexSourceDir);
59
+ const packagesDir = dirname(clientSourceDir);
60
+ const repoRoot = dirname(packagesDir);
61
+ const codexTargetDir = join(gmtrDir, 'codex');
62
+ const sharedHookUtilsSource = join(clientSourceDir, 'hooks', 'lib', 'gmtr-hook-utils.ts');
63
+ const sharedHookUtilsTarget = join(gmtrDir, 'hooks', 'lib', 'gmtr-hook-utils.ts');
64
+ const managedAgentsContent = readFileSync(join(repoRoot, 'AGENTS.md'), 'utf8');
65
+
66
+ ensureDir(gmtrDir);
67
+ ensureDir(codexHome);
68
+
69
+ copyRecursive(codexSourceDir, codexTargetDir);
70
+ copyRecursive(sharedHookUtilsSource, sharedHookUtilsTarget);
71
+ log(`OK Synced Codex runtime to ${codexTargetDir}`);
72
+
73
+ const managedHooks = buildManagedHooks(gmtrDir);
74
+ const existingHooks = readJsonFile(hooksPath, { hooks: {} });
75
+ const mergedHooks = mergeHooksFile(existingHooks, managedHooks);
76
+ writeFileSync(hooksPath, `${JSON.stringify(mergedHooks, null, 2)}\n`, 'utf8');
77
+ log(`OK Updated ${hooksPath}`);
78
+
79
+ const existingConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
80
+ const updatedConfig = ensureCodexHooksFeature(existingConfig);
81
+ writeFileSync(configPath, updatedConfig, 'utf8');
82
+ log(`OK Enabled Codex hooks in ${configPath}`);
83
+
84
+ const existingAgents = existsSync(agentsPath) ? readFileSync(agentsPath, 'utf8') : '';
85
+ const managedAgents = upsertManagedBlock(
86
+ existingAgents,
87
+ managedAgentsContent,
88
+ START_MARKER,
89
+ END_MARKER,
90
+ );
91
+ writeFileSync(agentsPath, managedAgents, 'utf8');
92
+ log(`OK Updated ${agentsPath}`);
93
+
94
+ log('');
95
+ log('Codex installer complete.');
96
+ log('Restart Codex or start a new session to load the updated hook configuration.');
97
+ }
98
+
99
+ main();
@@ -0,0 +1,48 @@
1
+ import {
2
+ buildHookFailureAdditionalContext,
3
+ buildSessionStartAdditionalContext,
4
+ buildUserPromptAdditionalContext,
5
+ } from '../../core/formatting.ts';
6
+ import type {
7
+ HandoffResponse,
8
+ HookFailure,
9
+ RouteResponse,
10
+ SessionStartResponse,
11
+ } from '../../core/types.ts';
12
+
13
+ export type {
14
+ HandoffResponse,
15
+ HookFailure,
16
+ RouteResponse,
17
+ SessionStartResponse,
18
+ };
19
+
20
+ export interface CodexHookOutput {
21
+ continue: boolean;
22
+ hookSpecificOutput: {
23
+ hookEventName: 'UserPromptSubmit' | 'SessionStart';
24
+ additionalContext: string;
25
+ };
26
+ systemMessage?: string;
27
+ }
28
+
29
+ export {
30
+ buildHookFailureAdditionalContext,
31
+ buildSessionStartAdditionalContext,
32
+ buildUserPromptAdditionalContext,
33
+ };
34
+
35
+ export function buildCodexHookOutput(
36
+ hookEventName: 'UserPromptSubmit' | 'SessionStart',
37
+ additionalContext: string,
38
+ systemMessage?: string,
39
+ ): CodexHookOutput {
40
+ return {
41
+ continue: true,
42
+ hookSpecificOutput: {
43
+ hookEventName,
44
+ additionalContext,
45
+ },
46
+ ...(systemMessage ? { systemMessage } : {}),
47
+ };
48
+ }