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,74 @@
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
+ buildGeminiHookOutput,
17
+ buildUserPromptAdditionalContext,
18
+ type RouteResponse,
19
+ } from '../lib/gemini-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 = buildGeminiHookOutput(
44
+ buildHookFailureAdditionalContext(failure),
45
+ 'gramatr request routing unavailable',
46
+ );
47
+ process.stdout.write(JSON.stringify(output));
48
+ }
49
+ return;
50
+ }
51
+
52
+ const additionalContext = buildUserPromptAdditionalContext(route);
53
+ if (git) {
54
+ persistClassificationResult({
55
+ rootDir: git.root,
56
+ prompt,
57
+ route,
58
+ downstreamModel: null,
59
+ clientType: 'gemini-cli',
60
+ agentName: 'Gemini CLI',
61
+ });
62
+ }
63
+ const output = buildGeminiHookOutput(
64
+ additionalContext,
65
+ 'gramatr request routing active',
66
+ );
67
+
68
+ process.stdout.write(JSON.stringify(output));
69
+ } catch {
70
+ // Never block the user prompt if the hook fails.
71
+ }
72
+ }
73
+
74
+ void main();
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+
3
+ import {
4
+ copyFileSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'fs';
12
+ import { dirname, join } from 'path';
13
+ import { fileURLToPath } from 'url';
14
+ import {
15
+ buildExtensionManifest,
16
+ buildGeminiHooksFile,
17
+ getGramatrExtensionDir,
18
+ readStoredApiKey,
19
+ } from './lib/gemini-install-utils.ts';
20
+
21
+ function log(message: string): void {
22
+ process.stdout.write(`${message}\n`);
23
+ }
24
+
25
+ function ensureDir(path: string): void {
26
+ if (!existsSync(path)) mkdirSync(path, { recursive: true });
27
+ }
28
+
29
+ function copyRecursive(source: string, target: string): void {
30
+ const stats = statSync(source);
31
+
32
+ if (stats.isDirectory()) {
33
+ ensureDir(target);
34
+ for (const entry of readdirSync(source)) {
35
+ copyRecursive(join(source, entry), join(target, entry));
36
+ }
37
+ return;
38
+ }
39
+
40
+ ensureDir(dirname(target));
41
+ copyFileSync(source, target);
42
+ }
43
+
44
+ async function promptForApiKey(): Promise<string | null> {
45
+ log('');
46
+ log(' gramatr requires authentication.');
47
+ log(' Options:');
48
+ log(' 1. Run `bun gmtr-login.ts` first to authenticate via browser');
49
+ log(' 2. Paste an API key below (starts with gmtr_sk_)');
50
+ log('');
51
+ process.stdout.write(' API Key (enter to skip): ');
52
+
53
+ const { createInterface } = await import('readline');
54
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
55
+ const key = await new Promise<string>((resolve) => {
56
+ rl.on('line', (line: string) => { rl.close(); resolve(line.trim()); });
57
+ });
58
+ return key || null;
59
+ }
60
+
61
+ async function testApiKey(key: string): Promise<boolean> {
62
+ try {
63
+ const res = await fetch('https://api.gramatr.com/mcp', {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ Accept: 'application/json, text/event-stream',
68
+ Authorization: `Bearer ${key}`,
69
+ },
70
+ body: JSON.stringify({
71
+ jsonrpc: '2.0',
72
+ id: 1,
73
+ method: 'tools/call',
74
+ params: { name: 'aggregate_stats', arguments: {} },
75
+ }),
76
+ signal: AbortSignal.timeout(10000),
77
+ });
78
+
79
+ const text = await res.text();
80
+ if (
81
+ text.includes('JWT token is required') ||
82
+ text.includes('signature validation failed') ||
83
+ text.includes('Unauthorized')
84
+ ) {
85
+ return false;
86
+ }
87
+
88
+ for (const line of text.split('\n')) {
89
+ if (line.startsWith('data: ')) {
90
+ try {
91
+ const d = JSON.parse(line.slice(6));
92
+ if (d?.result?.content?.[0]?.text && !d?.result?.isError) {
93
+ return true;
94
+ }
95
+ } catch {
96
+ continue;
97
+ }
98
+ }
99
+ }
100
+ return false;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ async function resolveApiKey(home: string): Promise<string | null> {
107
+ // 1. Check if already stored in ~/.gmtr.json (shared with other platforms)
108
+ const stored = readStoredApiKey(home);
109
+ if (stored) {
110
+ log(` Found existing token in ~/.gmtr.json`);
111
+ log(' Testing token...');
112
+ const valid = await testApiKey(stored);
113
+ if (valid) {
114
+ log(' OK Token is valid');
115
+ return stored;
116
+ }
117
+ log(' Token is invalid or expired. Please provide a new one.');
118
+ }
119
+
120
+ // 2. Check env var
121
+ const envKey = process.env.GRAMATR_API_KEY;
122
+ if (envKey) {
123
+ log(' Found GRAMATR_API_KEY in environment');
124
+ log(' Testing token...');
125
+ const valid = await testApiKey(envKey);
126
+ if (valid) {
127
+ log(' OK Token is valid');
128
+ return envKey;
129
+ }
130
+ log(' Environment token is invalid.');
131
+ }
132
+
133
+ // 3. Prompt
134
+ const prompted = await promptForApiKey();
135
+ if (prompted) {
136
+ log(' Testing token...');
137
+ const valid = await testApiKey(prompted);
138
+ if (valid) {
139
+ log(' OK Token is valid');
140
+ // Save to ~/.gmtr.json for cross-platform reuse
141
+ const configPath = join(home, '.gmtr.json');
142
+ let config: Record<string, unknown> = {};
143
+ if (existsSync(configPath)) {
144
+ try {
145
+ config = JSON.parse(readFileSync(configPath, 'utf8'));
146
+ } catch {
147
+ // start fresh
148
+ }
149
+ }
150
+ config.token = prompted;
151
+ config.token_type =
152
+ prompted.startsWith('gmtr_sk_') || prompted.startsWith('aios_sk_')
153
+ ? 'api_key'
154
+ : 'oauth';
155
+ config.authenticated_at = new Date().toISOString();
156
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
157
+ log(' OK Saved to ~/.gmtr.json');
158
+ return prompted;
159
+ }
160
+ log(' Token rejected by server.');
161
+ }
162
+
163
+ return null;
164
+ }
165
+
166
+ async function main(): Promise<void> {
167
+ const home = process.env.HOME || process.env.USERPROFILE;
168
+ if (!home) {
169
+ throw new Error('HOME is not set');
170
+ }
171
+
172
+ log('');
173
+ log(' gramatr Gemini CLI extension installer');
174
+ log(' ======================================');
175
+
176
+ // ── Resolve authentication ──
177
+ const apiKey = await resolveApiKey(home);
178
+ if (!apiKey) {
179
+ log('');
180
+ log(' WARNING: No valid API key configured.');
181
+ log(' The extension will be installed but MCP calls will fail without auth.');
182
+ log(' Run `bun gmtr-login.ts` to authenticate, then reinstall.');
183
+ log('');
184
+ }
185
+
186
+ // ── Determine paths ──
187
+ const currentFile = fileURLToPath(import.meta.url);
188
+ const geminiSourceDir = dirname(currentFile);
189
+ const clientSourceDir = dirname(geminiSourceDir);
190
+ const extensionDir = getGramatrExtensionDir(home);
191
+ const sharedHookUtilsSource = join(clientSourceDir, 'hooks', 'lib', 'gmtr-hook-utils.ts');
192
+ const sharedFeedbackSource = join(clientSourceDir, 'hooks', 'lib', 'classification-feedback.ts');
193
+ const sharedTranscriptSource = join(clientSourceDir, 'hooks', 'lib', 'transcript-parser.ts');
194
+ const sharedIdentitySource = join(clientSourceDir, 'hooks', 'lib', 'identity.ts');
195
+ const coreDir = join(clientSourceDir, 'core');
196
+
197
+ // ── Create extension directory ──
198
+ ensureDir(extensionDir);
199
+ ensureDir(join(extensionDir, 'hooks'));
200
+ ensureDir(join(extensionDir, 'hooks', 'lib'));
201
+ ensureDir(join(extensionDir, 'core'));
202
+
203
+ // ── Copy hook files ──
204
+ copyRecursive(join(geminiSourceDir, 'hooks'), join(extensionDir, 'hooks'));
205
+ copyRecursive(join(geminiSourceDir, 'lib'), join(extensionDir, 'lib'));
206
+ log(' OK Copied Gemini hook scripts');
207
+
208
+ // ── Copy shared dependencies ──
209
+ for (const sharedFile of [sharedHookUtilsSource, sharedFeedbackSource, sharedTranscriptSource, sharedIdentitySource]) {
210
+ if (existsSync(sharedFile)) {
211
+ const relativeDest = sharedFile.replace(clientSourceDir, '');
212
+ copyFileSync(sharedFile, join(extensionDir, relativeDest));
213
+ }
214
+ }
215
+
216
+ // Copy core shared modules
217
+ if (existsSync(coreDir)) {
218
+ for (const file of readdirSync(coreDir)) {
219
+ if (file.endsWith('.ts') && !file.endsWith('.test.ts')) {
220
+ copyFileSync(join(coreDir, file), join(extensionDir, 'core', file));
221
+ }
222
+ }
223
+ }
224
+ log(' OK Copied shared core and hook utilities');
225
+
226
+ // ── Write extension manifest ──
227
+ const manifest = buildExtensionManifest();
228
+ writeFileSync(
229
+ join(extensionDir, 'gemini-extension.json'),
230
+ JSON.stringify(manifest, null, 2) + '\n',
231
+ );
232
+ log(' OK Wrote gemini-extension.json manifest');
233
+
234
+ // ── Write hooks.json ──
235
+ const hooksFile = buildGeminiHooksFile();
236
+ writeFileSync(
237
+ join(extensionDir, 'hooks', 'hooks.json'),
238
+ JSON.stringify(hooksFile, null, 2) + '\n',
239
+ );
240
+ log(' OK Wrote hooks/hooks.json');
241
+
242
+ // ── Store token in ~/.gmtr.json (canonical source, not in extension dir) ──
243
+ if (apiKey) {
244
+ const gmtrJsonPath = join(home, '.gmtr.json');
245
+ let gmtrConfig: Record<string, unknown> = {};
246
+ if (existsSync(gmtrJsonPath)) {
247
+ try { gmtrConfig = JSON.parse(readFileSync(gmtrJsonPath, 'utf8')); } catch {}
248
+ }
249
+ gmtrConfig.token = apiKey;
250
+ gmtrConfig.token_updated_at = new Date().toISOString();
251
+ writeFileSync(gmtrJsonPath, JSON.stringify(gmtrConfig, null, 2) + '\n');
252
+ log(' OK Token stored in ~/.gmtr.json (hooks read from here at runtime)');
253
+ }
254
+
255
+ // ── Summary ──
256
+ log('');
257
+ log(` Extension installed to: ${extensionDir}`);
258
+ log('');
259
+ log(' What was installed:');
260
+ log(' - gemini-extension.json (MCP server + settings manifest)');
261
+ log(' - hooks/hooks.json (SessionStart, BeforeAgent, SessionEnd)');
262
+ log(' - hooks/*.ts (gramatr hook implementations)');
263
+ log(' - core/*.ts (shared routing/session/formatting logic)');
264
+ if (apiKey) {
265
+ log(' - .env (authenticated API key)');
266
+ }
267
+ log('');
268
+ log(' Restart Gemini CLI to load the extension.');
269
+ log('');
270
+ }
271
+
272
+ main();
@@ -0,0 +1,63 @@
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
+ /**
21
+ * Gemini CLI hook output envelope.
22
+ *
23
+ * Gemini hooks return JSON on stdout with these fields:
24
+ * continue — whether to proceed with the agent loop
25
+ * decision — "allow" | "deny" | "block" (for gating hooks)
26
+ * systemMessage — displayed in the terminal
27
+ * hookSpecificOutput.additionalContext — injected into the LLM context
28
+ *
29
+ * See: https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md
30
+ */
31
+ export interface GeminiHookOutput {
32
+ continue: boolean;
33
+ decision?: 'allow' | 'deny';
34
+ hookSpecificOutput?: {
35
+ additionalContext?: string;
36
+ };
37
+ systemMessage?: string;
38
+ suppressOutput?: boolean;
39
+ }
40
+
41
+ export {
42
+ buildHookFailureAdditionalContext,
43
+ buildSessionStartAdditionalContext,
44
+ buildUserPromptAdditionalContext,
45
+ };
46
+
47
+ export function buildGeminiHookOutput(
48
+ additionalContext: string,
49
+ systemMessage?: string,
50
+ ): GeminiHookOutput {
51
+ return {
52
+ continue: true,
53
+ ...(additionalContext
54
+ ? {
55
+ hookSpecificOutput: {
56
+ additionalContext,
57
+ },
58
+ }
59
+ : {}),
60
+ ...(systemMessage ? { systemMessage } : {}),
61
+ suppressOutput: true,
62
+ };
63
+ }
@@ -0,0 +1,169 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ export interface GeminiExtensionManifest {
5
+ name: string;
6
+ version: string;
7
+ description: string;
8
+ mcpServers?: Record<string, GeminiMcpServerConfig>;
9
+ settings?: GeminiSettingDef[];
10
+ contextFileName?: string;
11
+ }
12
+
13
+ export interface GeminiMcpServerConfig {
14
+ httpUrl?: string;
15
+ headers?: Record<string, string>;
16
+ timeout?: number;
17
+ }
18
+
19
+ export interface GeminiSettingDef {
20
+ name: string;
21
+ envVar: string;
22
+ sensitive?: boolean;
23
+ description?: string;
24
+ }
25
+
26
+ export interface GeminiHooksFileHook {
27
+ type: 'command';
28
+ command: string;
29
+ name?: string;
30
+ timeout?: number;
31
+ description?: string;
32
+ }
33
+
34
+ export interface GeminiHooksFileEntry {
35
+ matcher?: string;
36
+ sequential?: boolean;
37
+ hooks: GeminiHooksFileHook[];
38
+ }
39
+
40
+ export interface GeminiHooksFile {
41
+ hooks: Record<string, GeminiHooksFileEntry[]>;
42
+ }
43
+
44
+ /**
45
+ * Build the gemini-extension.json manifest for gramatr.
46
+ *
47
+ * The mcpServers block points to the production MCP endpoint.
48
+ * Auth is handled via the GRAMATR_API_KEY setting which Gemini stores
49
+ * in its extension .env / system keychain.
50
+ */
51
+ export function buildExtensionManifest(): GeminiExtensionManifest {
52
+ return {
53
+ name: 'gramatr',
54
+ version: '1.0.0',
55
+ description: 'gramatr intelligence layer — decision routing, vector memory, and pattern learning for Gemini CLI',
56
+ mcpServers: {
57
+ gramatr: {
58
+ httpUrl: 'https://api.gramatr.com/mcp',
59
+ headers: {
60
+ Authorization: 'Bearer ${GRAMATR_API_KEY}',
61
+ },
62
+ timeout: 30000,
63
+ },
64
+ },
65
+ settings: [
66
+ {
67
+ name: 'API Key',
68
+ envVar: 'GRAMATR_API_KEY',
69
+ sensitive: true,
70
+ description: 'gramatr API key (starts with gmtr_sk_). Get one at gramatr.com or via gmtr-login.',
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ /**
77
+ * Build the hooks/hooks.json for the gramatr Gemini extension.
78
+ *
79
+ * Maps Gemini CLI lifecycle events to gramatr hook scripts.
80
+ * Uses ${extensionPath} for portability — Gemini CLI expands this
81
+ * to the installed extension directory at runtime.
82
+ */
83
+ export function buildGeminiHooksFile(): GeminiHooksFile {
84
+ return {
85
+ hooks: {
86
+ SessionStart: [
87
+ {
88
+ hooks: [
89
+ {
90
+ type: 'command',
91
+ command: 'bun "${extensionPath}/hooks/session-start.ts"',
92
+ name: 'gramatr-session-start',
93
+ timeout: 15,
94
+ description: 'Load gramatr session context and handoff',
95
+ },
96
+ ],
97
+ },
98
+ ],
99
+ BeforeAgent: [
100
+ {
101
+ hooks: [
102
+ {
103
+ type: 'command',
104
+ command: 'bun "${extensionPath}/hooks/user-prompt-submit.ts"',
105
+ name: 'gramatr-prompt-routing',
106
+ timeout: 15,
107
+ description: 'Route prompt through gramatr intelligence',
108
+ },
109
+ ],
110
+ },
111
+ ],
112
+ SessionEnd: [
113
+ {
114
+ hooks: [
115
+ {
116
+ type: 'command',
117
+ command: 'bun "${extensionPath}/hooks/stop.ts"',
118
+ name: 'gramatr-session-end',
119
+ timeout: 10,
120
+ description: 'Submit classification feedback to gramatr',
121
+ },
122
+ ],
123
+ },
124
+ ],
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Read existing Gemini settings.json and extract the mcpServers block.
131
+ * Returns null if file doesn't exist or can't be parsed.
132
+ */
133
+ export function readGeminiSettings(geminiHome: string): Record<string, unknown> | null {
134
+ const settingsPath = join(geminiHome, 'settings.json');
135
+ if (!existsSync(settingsPath)) return null;
136
+ try {
137
+ return JSON.parse(readFileSync(settingsPath, 'utf8'));
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Resolve the Gemini extensions directory.
145
+ */
146
+ export function getGeminiExtensionsDir(home: string): string {
147
+ return join(home, '.gemini', 'extensions');
148
+ }
149
+
150
+ /**
151
+ * Resolve the gramatr extension install path.
152
+ */
153
+ export function getGramatrExtensionDir(home: string): string {
154
+ return join(getGeminiExtensionsDir(home), 'gramatr');
155
+ }
156
+
157
+ /**
158
+ * Read the stored API key from ~/.gmtr.json (shared with other platforms).
159
+ */
160
+ export function readStoredApiKey(home: string): string | null {
161
+ const configPath = join(home, '.gmtr.json');
162
+ if (!existsSync(configPath)) return null;
163
+ try {
164
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
165
+ return config.token || null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }