@gramatr/client 0.5.1
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.
- package/AGENTS.md +17 -0
- package/CLAUDE.md +18 -0
- package/README.md +108 -0
- package/bin/add-api-key.ts +264 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/clear-creds.ts +141 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +599 -0
- package/bin/gramatr.js +36 -0
- package/bin/gramatr.ts +374 -0
- package/bin/install.ts +716 -0
- package/bin/lib/config.ts +57 -0
- package/bin/lib/git.ts +111 -0
- package/bin/lib/stdin.ts +53 -0
- package/bin/logout.ts +76 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +81 -0
- package/bin/uninstall.ts +289 -0
- package/chatgpt/README.md +95 -0
- package/chatgpt/install.ts +140 -0
- package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +79 -0
- package/codex/install.ts +116 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/auth.ts +170 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +179 -0
- package/core/install.ts +107 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +479 -0
- package/core/routing.ts +108 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +179 -0
- package/core/version-check.ts +219 -0
- package/core/version.ts +47 -0
- package/desktop/README.md +72 -0
- package/desktop/build-mcpb.ts +166 -0
- package/desktop/install.ts +136 -0
- package/desktop/lib/desktop-install-utils.ts +70 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +77 -0
- package/gemini/install.ts +281 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +651 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +770 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +501 -0
- package/package.json +63 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { join } from 'path';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MCP_URL = 'https://mcp.gramatr.com/mcp';
|
|
4
|
+
|
|
5
|
+
export interface DesktopMcpServerEntry {
|
|
6
|
+
gramatr: {
|
|
7
|
+
url: string;
|
|
8
|
+
headers: {
|
|
9
|
+
Authorization: string;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface DesktopConfig {
|
|
15
|
+
mcpServers?: Record<string, unknown>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns the Claude Desktop config file path for the given platform.
|
|
21
|
+
*
|
|
22
|
+
* macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
23
|
+
* Windows: %APPDATA%\Claude\claude_desktop_config.json (APPDATA = <home>\AppData\Roaming)
|
|
24
|
+
*/
|
|
25
|
+
export function getDesktopConfigPath(
|
|
26
|
+
home: string,
|
|
27
|
+
platform: string = process.platform,
|
|
28
|
+
): string {
|
|
29
|
+
if (platform === 'win32') {
|
|
30
|
+
return join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// macOS (darwin) and fallback for other platforms
|
|
34
|
+
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build the mcpServers.gramatr entry for Claude Desktop config.
|
|
39
|
+
* Uses StreamableHTTP transport — Claude Desktop connects directly to the remote URL.
|
|
40
|
+
*/
|
|
41
|
+
export function buildMcpServerEntry(
|
|
42
|
+
apiKey: string,
|
|
43
|
+
serverUrl: string = DEFAULT_MCP_URL,
|
|
44
|
+
): DesktopMcpServerEntry {
|
|
45
|
+
return {
|
|
46
|
+
gramatr: {
|
|
47
|
+
url: serverUrl,
|
|
48
|
+
headers: {
|
|
49
|
+
Authorization: `Bearer ${apiKey}`,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Safely merge a gramatr MCP server entry into an existing Claude Desktop config.
|
|
57
|
+
* Preserves all other mcpServers and top-level config keys.
|
|
58
|
+
*/
|
|
59
|
+
export function mergeDesktopConfig(
|
|
60
|
+
existing: DesktopConfig,
|
|
61
|
+
gramatrEntry: DesktopMcpServerEntry,
|
|
62
|
+
): DesktopConfig {
|
|
63
|
+
return {
|
|
64
|
+
...existing,
|
|
65
|
+
mcpServers: {
|
|
66
|
+
...(existing.mcpServers || {}),
|
|
67
|
+
...gramatrEntry,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/gemini/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Gemini CLI Integration
|
|
2
|
+
|
|
3
|
+
This directory contains the Gemini CLI platform shim for gramatr.
|
|
4
|
+
|
|
5
|
+
gramatr is an intelligent AI middleware that pre-classifies every request using a local model before expensive LLMs process them, saving tokens on every interaction. It provides persistent vector-indexed memory, decision routing, pattern learning, and predictive suggestions across sessions and platforms.
|
|
6
|
+
|
|
7
|
+
## Architecture
|
|
8
|
+
|
|
9
|
+
The Gemini CLI integration follows the same platform shim pattern as the Codex integration:
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
gemini/
|
|
13
|
+
install.ts -- installer: copies runtime to ~/.gemini/extensions/gramatr/
|
|
14
|
+
hooks/
|
|
15
|
+
session-start.ts -- SessionStart: loads session context + handoff
|
|
16
|
+
user-prompt-submit.ts -- BeforeAgent: routes prompts through gramatr intelligence
|
|
17
|
+
stop.ts -- SessionEnd: submits classification feedback
|
|
18
|
+
lib/
|
|
19
|
+
gemini-hook-utils.ts -- Gemini-specific output formatting (GeminiHookOutput envelope)
|
|
20
|
+
gemini-install-utils.ts -- manifest builder, hooks.json builder, install helpers
|
|
21
|
+
README.md
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Hooks are thin adapters that call shared core functions from `core/routing.ts`, `core/session.ts`, and `core/formatting.ts`. Platform-specific concerns (output envelope format) live in `lib/gemini-hook-utils.ts`.
|
|
25
|
+
|
|
26
|
+
## How It Works
|
|
27
|
+
|
|
28
|
+
Gemini CLI supports extensions via `~/.gemini/extensions/<name>/`. Each extension has a `gemini-extension.json` manifest that can define MCP servers, hooks, settings, and custom commands.
|
|
29
|
+
|
|
30
|
+
The gramatr extension:
|
|
31
|
+
1. Registers the gramatr MCP server (Streamable HTTP at `api.gramatr.com/mcp`)
|
|
32
|
+
2. Hooks into `SessionStart` to load project context and handoff state
|
|
33
|
+
3. Hooks into `BeforeAgent` to route every prompt through gramatr intelligence (effort classification, capability audit, ISC scaffold, memory pre-load)
|
|
34
|
+
4. Hooks into `SessionEnd` to submit classification feedback for the learning flywheel
|
|
35
|
+
5. Declares `GRAMATR_API_KEY` as a sensitive setting (stored in system keychain by Gemini CLI)
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
### Option A: From the monorepo
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pnpm --filter @gramatr/client install-gemini
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Option B: Direct
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
cd packages/client && bun gemini/install.ts
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Option C: Manual (advanced)
|
|
52
|
+
|
|
53
|
+
Copy the extension to the Gemini extensions directory and configure auth:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
mkdir -p ~/.gemini/extensions/gramatr
|
|
57
|
+
cp -r packages/client/gemini/* ~/.gemini/extensions/gramatr/
|
|
58
|
+
echo "GRAMATR_API_KEY=your-key-here" > ~/.gemini/extensions/gramatr/.env
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Authentication
|
|
62
|
+
|
|
63
|
+
gramatr requires a Bearer token for all MCP calls. The installer handles this by:
|
|
64
|
+
|
|
65
|
+
1. Checking `~/.gmtr.json` for an existing token (shared with Claude Code / Codex)
|
|
66
|
+
2. Checking the `GRAMATR_API_KEY` environment variable
|
|
67
|
+
3. Prompting for a token interactively
|
|
68
|
+
|
|
69
|
+
To authenticate before installing, run:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
bun packages/client/bin/gmtr-login.ts
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This stores the token in `~/.gmtr.json`, which the installer reads automatically.
|
|
76
|
+
|
|
77
|
+
API keys start with `gmtr_sk_` and can be created at [gramatr.com](https://gramatr.com) or via the `gmtr_create_api_key` MCP tool.
|
|
78
|
+
|
|
79
|
+
## Hook Event Mapping
|
|
80
|
+
|
|
81
|
+
| Gemini CLI Event | gramatr Hook | Purpose |
|
|
82
|
+
|------------------|-------------|---------|
|
|
83
|
+
| `SessionStart` | `session-start.ts` | Register session, load handoff context |
|
|
84
|
+
| `BeforeAgent` | `user-prompt-submit.ts` | Pre-classify prompt, inject intelligence packet |
|
|
85
|
+
| `SessionEnd` | `stop.ts` | Submit classification feedback |
|
|
86
|
+
|
|
87
|
+
## Verifying the Installation
|
|
88
|
+
|
|
89
|
+
After installing and restarting Gemini CLI:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
> @gramatr search for recent learning signals
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
If the MCP server responds, the extension is working. If you see auth errors, re-run the installer or check `~/.gmtr.json`.
|
|
@@ -0,0 +1,72 @@
|
|
|
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
|
+
buildGeminiHookOutput,
|
|
16
|
+
buildSessionStartAdditionalContext,
|
|
17
|
+
type HandoffResponse,
|
|
18
|
+
type SessionStartResponse,
|
|
19
|
+
} from '../lib/gemini-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: 'gemini-cli',
|
|
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 = buildGeminiHookOutput(
|
|
62
|
+
additionalContext,
|
|
63
|
+
'gramatr session context loaded',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
process.stdout.write(JSON.stringify(output));
|
|
67
|
+
} catch {
|
|
68
|
+
// Never block startup if the hook fails.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
void main();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getGitContext,
|
|
5
|
+
readHookInput,
|
|
6
|
+
} from '../../hooks/lib/gmtr-hook-utils.ts';
|
|
7
|
+
import { submitPendingClassificationFeedback } from '../../hooks/lib/classification-feedback.ts';
|
|
8
|
+
|
|
9
|
+
async function main(): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
const input = await readHookInput();
|
|
12
|
+
if (!input.session_id) return;
|
|
13
|
+
|
|
14
|
+
const git = getGitContext();
|
|
15
|
+
if (!git) return;
|
|
16
|
+
|
|
17
|
+
await submitPendingClassificationFeedback({
|
|
18
|
+
rootDir: git.root,
|
|
19
|
+
sessionId: input.session_id,
|
|
20
|
+
originalPrompt: '',
|
|
21
|
+
clientType: 'gemini-cli',
|
|
22
|
+
agentName: 'Gemini CLI',
|
|
23
|
+
downstreamProvider: 'google',
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
// Never block completion if the hook fails.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
void main();
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
// #496 Phase 3: ask the server for a composed [GMTR Status] block.
|
|
38
|
+
includeStatusline: true,
|
|
39
|
+
statuslineSize: 'small',
|
|
40
|
+
});
|
|
41
|
+
const route = result.route as RouteResponse | null;
|
|
42
|
+
|
|
43
|
+
if (!route) {
|
|
44
|
+
if (result.error) {
|
|
45
|
+
const failure = describeRoutingFailure(result.error);
|
|
46
|
+
const output = buildGeminiHookOutput(
|
|
47
|
+
buildHookFailureAdditionalContext(failure),
|
|
48
|
+
'gramatr request routing unavailable',
|
|
49
|
+
);
|
|
50
|
+
process.stdout.write(JSON.stringify(output));
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const additionalContext = buildUserPromptAdditionalContext(route);
|
|
56
|
+
if (git) {
|
|
57
|
+
persistClassificationResult({
|
|
58
|
+
rootDir: git.root,
|
|
59
|
+
prompt,
|
|
60
|
+
route,
|
|
61
|
+
downstreamModel: null,
|
|
62
|
+
clientType: 'gemini-cli',
|
|
63
|
+
agentName: 'Gemini CLI',
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const output = buildGeminiHookOutput(
|
|
67
|
+
additionalContext,
|
|
68
|
+
'gramatr request routing active',
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
process.stdout.write(JSON.stringify(output));
|
|
72
|
+
} catch {
|
|
73
|
+
// Never block the user prompt if the hook fails.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
void main();
|
|
@@ -0,0 +1,281 @@
|
|
|
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 (skip in non-interactive mode)
|
|
134
|
+
const nonInteractive = process.argv.includes('--yes') || process.argv.includes('-y') || !process.stdin.isTTY;
|
|
135
|
+
if (nonInteractive) {
|
|
136
|
+
log(' Non-interactive: no token found. Run gmtr-login after install.');
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const prompted = await promptForApiKey();
|
|
140
|
+
if (prompted) {
|
|
141
|
+
log(' Testing token...');
|
|
142
|
+
const valid = await testApiKey(prompted);
|
|
143
|
+
if (valid) {
|
|
144
|
+
log(' OK Token is valid');
|
|
145
|
+
// Save to ~/.gmtr.json for cross-platform reuse
|
|
146
|
+
const configPath = join(home, '.gmtr.json');
|
|
147
|
+
let config: Record<string, unknown> = {};
|
|
148
|
+
if (existsSync(configPath)) {
|
|
149
|
+
try {
|
|
150
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
151
|
+
} catch {
|
|
152
|
+
// start fresh
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
config.token = prompted;
|
|
156
|
+
config.token_type =
|
|
157
|
+
prompted.startsWith('gmtr_sk_') || prompted.startsWith('aios_sk_')
|
|
158
|
+
? 'api_key'
|
|
159
|
+
: 'oauth';
|
|
160
|
+
config.authenticated_at = new Date().toISOString();
|
|
161
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
162
|
+
log(' OK Saved to ~/.gmtr.json');
|
|
163
|
+
return prompted;
|
|
164
|
+
}
|
|
165
|
+
log(' Token rejected by server.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function main(): Promise<void> {
|
|
172
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
173
|
+
if (!home) {
|
|
174
|
+
throw new Error('HOME is not set');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
log('');
|
|
178
|
+
log(' gramatr Gemini CLI extension installer');
|
|
179
|
+
log(' ======================================');
|
|
180
|
+
|
|
181
|
+
// ── Resolve authentication ──
|
|
182
|
+
const apiKey = await resolveApiKey(home);
|
|
183
|
+
if (!apiKey) {
|
|
184
|
+
log('');
|
|
185
|
+
log(' WARNING: No valid API key configured.');
|
|
186
|
+
log(' The extension will be installed but MCP calls will fail without auth.');
|
|
187
|
+
log(' Run `bun gmtr-login.ts` to authenticate, then reinstall.');
|
|
188
|
+
log('');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Determine paths ──
|
|
192
|
+
const currentFile = fileURLToPath(import.meta.url);
|
|
193
|
+
const geminiSourceDir = dirname(currentFile);
|
|
194
|
+
const clientSourceDir = dirname(geminiSourceDir);
|
|
195
|
+
const extensionDir = getGramatrExtensionDir(home);
|
|
196
|
+
const sharedHookUtilsSource = join(clientSourceDir, 'hooks', 'lib', 'gmtr-hook-utils.ts');
|
|
197
|
+
const sharedFeedbackSource = join(clientSourceDir, 'hooks', 'lib', 'classification-feedback.ts');
|
|
198
|
+
const sharedTranscriptSource = join(clientSourceDir, 'hooks', 'lib', 'transcript-parser.ts');
|
|
199
|
+
const sharedIdentitySource = join(clientSourceDir, 'hooks', 'lib', 'identity.ts');
|
|
200
|
+
const coreDir = join(clientSourceDir, 'core');
|
|
201
|
+
|
|
202
|
+
// ── Create extension directory ──
|
|
203
|
+
ensureDir(extensionDir);
|
|
204
|
+
ensureDir(join(extensionDir, 'hooks'));
|
|
205
|
+
ensureDir(join(extensionDir, 'hooks', 'lib'));
|
|
206
|
+
ensureDir(join(extensionDir, 'core'));
|
|
207
|
+
|
|
208
|
+
// ── Copy hook files ──
|
|
209
|
+
copyRecursive(join(geminiSourceDir, 'hooks'), join(extensionDir, 'hooks'));
|
|
210
|
+
copyRecursive(join(geminiSourceDir, 'lib'), join(extensionDir, 'lib'));
|
|
211
|
+
log(' OK Copied Gemini hook scripts');
|
|
212
|
+
|
|
213
|
+
// ── Copy shared dependencies ──
|
|
214
|
+
for (const sharedFile of [sharedHookUtilsSource, sharedFeedbackSource, sharedTranscriptSource, sharedIdentitySource]) {
|
|
215
|
+
if (existsSync(sharedFile)) {
|
|
216
|
+
const relativeDest = sharedFile.replace(clientSourceDir, '');
|
|
217
|
+
copyFileSync(sharedFile, join(extensionDir, relativeDest));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Copy core shared modules
|
|
222
|
+
if (existsSync(coreDir)) {
|
|
223
|
+
for (const file of readdirSync(coreDir)) {
|
|
224
|
+
if (file.endsWith('.ts') && !file.endsWith('.test.ts')) {
|
|
225
|
+
copyFileSync(join(coreDir, file), join(extensionDir, 'core', file));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
log(' OK Copied shared core and hook utilities');
|
|
230
|
+
|
|
231
|
+
// ── Write extension manifest ──
|
|
232
|
+
const manifest = buildExtensionManifest();
|
|
233
|
+
writeFileSync(
|
|
234
|
+
join(extensionDir, 'gemini-extension.json'),
|
|
235
|
+
JSON.stringify(manifest, null, 2) + '\n',
|
|
236
|
+
);
|
|
237
|
+
log(' OK Wrote gemini-extension.json manifest');
|
|
238
|
+
|
|
239
|
+
// ── Write hooks.json ──
|
|
240
|
+
const hooksFile = buildGeminiHooksFile();
|
|
241
|
+
writeFileSync(
|
|
242
|
+
join(extensionDir, 'hooks', 'hooks.json'),
|
|
243
|
+
JSON.stringify(hooksFile, null, 2) + '\n',
|
|
244
|
+
);
|
|
245
|
+
log(' OK Wrote hooks/hooks.json');
|
|
246
|
+
|
|
247
|
+
// ── Store token in ~/.gmtr.json (canonical source, not in extension dir) ──
|
|
248
|
+
if (apiKey) {
|
|
249
|
+
const gmtrJsonPath = join(home, '.gmtr.json');
|
|
250
|
+
let gmtrConfig: Record<string, unknown> = {};
|
|
251
|
+
if (existsSync(gmtrJsonPath)) {
|
|
252
|
+
try { gmtrConfig = JSON.parse(readFileSync(gmtrJsonPath, 'utf8')); } catch {}
|
|
253
|
+
}
|
|
254
|
+
gmtrConfig.token = apiKey;
|
|
255
|
+
gmtrConfig.token_updated_at = new Date().toISOString();
|
|
256
|
+
writeFileSync(gmtrJsonPath, JSON.stringify(gmtrConfig, null, 2) + '\n');
|
|
257
|
+
log(' OK Token stored in ~/.gmtr.json (hooks read from here at runtime)');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Summary ──
|
|
261
|
+
log('');
|
|
262
|
+
log(` Extension installed to: ${extensionDir}`);
|
|
263
|
+
log('');
|
|
264
|
+
log(' What was installed:');
|
|
265
|
+
log(' - gemini-extension.json (MCP server + settings manifest)');
|
|
266
|
+
log(' - hooks/hooks.json (SessionStart, BeforeAgent, SessionEnd)');
|
|
267
|
+
log(' - hooks/*.ts (gramatr hook implementations)');
|
|
268
|
+
log(' - core/*.ts (shared routing/session/formatting logic)');
|
|
269
|
+
if (apiKey) {
|
|
270
|
+
log(' - .env (authenticated API key)');
|
|
271
|
+
}
|
|
272
|
+
log('');
|
|
273
|
+
log(' Restart Gemini CLI to load the extension.');
|
|
274
|
+
log('');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Run directly when executed as a script
|
|
278
|
+
const isDirectRun = typeof require !== 'undefined'
|
|
279
|
+
? require.main === module
|
|
280
|
+
: !import.meta.url.includes('node_modules/gramatr/bin/');
|
|
281
|
+
if (isDirectRun) 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
|
+
}
|