gramatr 0.3.56 → 0.3.58
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/bin/add-api-key.ts +264 -0
- package/bin/gramatr.ts +12 -0
- package/bin/install.ts +23 -51
- package/bin/logout.ts +76 -0
- package/chatgpt/README.md +95 -0
- package/chatgpt/install.ts +140 -0
- package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
- package/core/auth.ts +170 -0
- package/desktop/README.md +72 -0
- package/desktop/build-mcpb.ts +167 -0
- package/desktop/install.ts +136 -0
- package/desktop/lib/desktop-install-utils.ts +70 -0
- package/package.json +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gramatr add-api-key — Explicit API key ingestion command (issue #484).
|
|
4
|
+
*
|
|
5
|
+
* Three modes:
|
|
6
|
+
* 1. Interactive prompt: gramatr add-api-key
|
|
7
|
+
* 2. Piped stdin: echo "gmtr_sk_..." | gramatr add-api-key
|
|
8
|
+
* 3. Env-sourced: gramatr add-api-key --from-env GRAMATR_API_KEY
|
|
9
|
+
*
|
|
10
|
+
* The key is validated against the gramatr server before being written
|
|
11
|
+
* to ~/.gmtr.json. Use --force to skip server validation when offline.
|
|
12
|
+
*
|
|
13
|
+
* This command is the ONLY way to put an API key into ~/.gmtr.json.
|
|
14
|
+
* Installers never prompt for API keys — see packages/client/core/auth.ts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { chmodSync, existsSync, readFileSync, writeFileSync } from "fs";
|
|
18
|
+
import { homedir } from "os";
|
|
19
|
+
import { join } from "path";
|
|
20
|
+
import { createInterface } from "readline";
|
|
21
|
+
|
|
22
|
+
function gmtrJsonPath(): string {
|
|
23
|
+
return join(process.env.HOME || process.env.USERPROFILE || homedir(), ".gmtr.json");
|
|
24
|
+
}
|
|
25
|
+
const SERVER_BASE = (process.env.GMTR_URL || "https://api.gramatr.com").replace(/\/mcp\/?$/, "");
|
|
26
|
+
|
|
27
|
+
// Accept gmtr_sk_, gmtr_pk_, aios_sk_, aios_pk_ (legacy), and Firebase-style
|
|
28
|
+
// long opaque tokens (length >= 32, base64url-ish characters).
|
|
29
|
+
const KEY_FORMAT = /^(gmtr|aios)_(sk|pk)_[A-Za-z0-9_-]+$/;
|
|
30
|
+
const LEGACY_OPAQUE = /^[A-Za-z0-9_.-]{32,}$/;
|
|
31
|
+
|
|
32
|
+
function log(msg: string = ""): void {
|
|
33
|
+
process.stdout.write(`${msg}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function err(msg: string): void {
|
|
37
|
+
process.stderr.write(`${msg}\n`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv: string[]): {
|
|
41
|
+
fromEnv?: string;
|
|
42
|
+
force: boolean;
|
|
43
|
+
help: boolean;
|
|
44
|
+
} {
|
|
45
|
+
let fromEnv: string | undefined;
|
|
46
|
+
let force = false;
|
|
47
|
+
let help = false;
|
|
48
|
+
for (let i = 0; i < argv.length; i++) {
|
|
49
|
+
const a = argv[i];
|
|
50
|
+
if (a === "--from-env") {
|
|
51
|
+
fromEnv = argv[++i];
|
|
52
|
+
} else if (a === "--force") {
|
|
53
|
+
force = true;
|
|
54
|
+
} else if (a === "--help" || a === "-h") {
|
|
55
|
+
help = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { fromEnv, force, help };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function showHelp(): void {
|
|
62
|
+
log(`gramatr add-api-key — Add a gramatr API key to ~/.gmtr.json
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
gramatr add-api-key Interactive prompt for the key
|
|
66
|
+
echo "gmtr_sk_..." | gramatr add-api-key Read key from piped stdin
|
|
67
|
+
gramatr add-api-key --from-env VAR Read key from named env variable
|
|
68
|
+
gramatr add-api-key --force Skip server validation (offline use)
|
|
69
|
+
|
|
70
|
+
The key is validated against the gramatr server before being written.
|
|
71
|
+
Server: ${SERVER_BASE}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateFormat(key: string): boolean {
|
|
75
|
+
if (KEY_FORMAT.test(key)) return true;
|
|
76
|
+
// Allow legacy opaque tokens (e.g. Firebase IDs) — must still be sane.
|
|
77
|
+
if (LEGACY_OPAQUE.test(key) && !key.includes(" ")) return true;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readPipedStdin(): Promise<string | null> {
|
|
82
|
+
if (process.stdin.isTTY) return null;
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const chunks: Buffer[] = [];
|
|
85
|
+
process.stdin.on("data", (c) => chunks.push(Buffer.from(c)));
|
|
86
|
+
process.stdin.on("end", () => {
|
|
87
|
+
const out = Buffer.concat(chunks).toString("utf8").trim();
|
|
88
|
+
resolve(out || null);
|
|
89
|
+
});
|
|
90
|
+
process.stdin.on("error", () => resolve(null));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function readInteractive(): Promise<string> {
|
|
95
|
+
log("");
|
|
96
|
+
log("Paste your gramatr API key below.");
|
|
97
|
+
log("(Get one at https://gramatr.com/settings — keys start with gmtr_sk_)");
|
|
98
|
+
log("");
|
|
99
|
+
process.stdout.write(" Key: ");
|
|
100
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
101
|
+
return new Promise((resolve) => {
|
|
102
|
+
rl.on("line", (line: string) => {
|
|
103
|
+
rl.close();
|
|
104
|
+
resolve(line.trim());
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface ValidationResult {
|
|
110
|
+
ok: boolean;
|
|
111
|
+
status?: number;
|
|
112
|
+
error?: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function validateAgainstServer(key: string): Promise<ValidationResult> {
|
|
116
|
+
// Use the MCP aggregate_stats path the same way gmtr-login.ts does —
|
|
117
|
+
// this is the lightest authenticated endpoint we know works on every
|
|
118
|
+
// deployment without requiring a /api/v1/me route.
|
|
119
|
+
try {
|
|
120
|
+
const res = await fetch(`${SERVER_BASE}/mcp`, {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: {
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
Accept: "application/json, text/event-stream",
|
|
125
|
+
Authorization: `Bearer ${key}`,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
jsonrpc: "2.0",
|
|
129
|
+
id: 1,
|
|
130
|
+
method: "tools/call",
|
|
131
|
+
params: { name: "aggregate_stats", arguments: {} },
|
|
132
|
+
}),
|
|
133
|
+
signal: AbortSignal.timeout(10000),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
|
|
138
|
+
if (res.status === 401 || res.status === 403) {
|
|
139
|
+
return { ok: false, status: res.status, error: "Server rejected key (401/403)" };
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
text.includes("JWT token is required") ||
|
|
143
|
+
text.includes("signature validation failed") ||
|
|
144
|
+
text.includes("Unauthorized")
|
|
145
|
+
) {
|
|
146
|
+
return { ok: false, status: 401, error: "Server rejected key" };
|
|
147
|
+
}
|
|
148
|
+
if (res.status >= 500) {
|
|
149
|
+
return { ok: false, status: res.status, error: `Server error HTTP ${res.status}` };
|
|
150
|
+
}
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
153
|
+
}
|
|
154
|
+
return { ok: true, status: res.status };
|
|
155
|
+
} catch (e: any) {
|
|
156
|
+
return { ok: false, error: e?.message || "Network failure" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeKey(key: string): void {
|
|
161
|
+
let existing: Record<string, any> = {};
|
|
162
|
+
if (existsSync(gmtrJsonPath())) {
|
|
163
|
+
try {
|
|
164
|
+
existing = JSON.parse(readFileSync(gmtrJsonPath(), "utf8"));
|
|
165
|
+
} catch {
|
|
166
|
+
existing = {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
existing.token = key;
|
|
170
|
+
existing.token_type =
|
|
171
|
+
key.startsWith("gmtr_sk_") || key.startsWith("aios_sk_") ? "api_key" : "oauth";
|
|
172
|
+
existing.authenticated_at = new Date().toISOString();
|
|
173
|
+
writeFileSync(gmtrJsonPath(), `${JSON.stringify(existing, null, 2)}\n`, "utf8");
|
|
174
|
+
try {
|
|
175
|
+
chmodSync(gmtrJsonPath(), 0o600);
|
|
176
|
+
} catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<number> {
|
|
182
|
+
const opts = parseArgs(argv);
|
|
183
|
+
if (opts.help) {
|
|
184
|
+
showHelp();
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let key: string | null = null;
|
|
189
|
+
|
|
190
|
+
// Source 1: --from-env
|
|
191
|
+
if (opts.fromEnv) {
|
|
192
|
+
const v = process.env[opts.fromEnv];
|
|
193
|
+
if (!v || !v.trim()) {
|
|
194
|
+
err(`ERROR: env var ${opts.fromEnv} is unset or empty`);
|
|
195
|
+
return 1;
|
|
196
|
+
}
|
|
197
|
+
key = v.trim();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Source 2: piped stdin
|
|
201
|
+
if (!key) {
|
|
202
|
+
key = await readPipedStdin();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Source 3: interactive
|
|
206
|
+
if (!key && process.stdin.isTTY) {
|
|
207
|
+
key = (await readInteractive()).trim();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!key) {
|
|
211
|
+
err("ERROR: no API key provided. See `gramatr add-api-key --help`.");
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Format validation
|
|
216
|
+
if (!validateFormat(key)) {
|
|
217
|
+
err("ERROR: key format is invalid. Expected gmtr_sk_... or gmtr_pk_...");
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Server validation
|
|
222
|
+
if (!opts.force) {
|
|
223
|
+
log("Validating key against gramatr server...");
|
|
224
|
+
const result = await validateAgainstServer(key);
|
|
225
|
+
if (!result.ok) {
|
|
226
|
+
if (result.status === 401 || result.status === 403) {
|
|
227
|
+
err(`ERROR: server rejected key — ${result.error}`);
|
|
228
|
+
err("Key was NOT written.");
|
|
229
|
+
return 1;
|
|
230
|
+
}
|
|
231
|
+
// Network or 5xx — surface as warning, exit non-zero unless --force.
|
|
232
|
+
err(`WARN: could not validate key — ${result.error}`);
|
|
233
|
+
err("Key was NOT written. Re-run with --force to skip server validation.");
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
log(" OK Server accepted key");
|
|
237
|
+
} else {
|
|
238
|
+
log(" Skipping server validation (--force)");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
writeKey(key);
|
|
242
|
+
log(`OK Key written to ${gmtrJsonPath()}`);
|
|
243
|
+
log("gramatr is now authenticated.");
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Only auto-run when invoked directly, not when imported by tests.
|
|
248
|
+
const isDirect = (() => {
|
|
249
|
+
try {
|
|
250
|
+
const invoked = process.argv[1] || "";
|
|
251
|
+
return invoked.endsWith("add-api-key.ts") || invoked.endsWith("add-api-key.js");
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
|
|
257
|
+
if (isDirect) {
|
|
258
|
+
main()
|
|
259
|
+
.then((code) => process.exit(code))
|
|
260
|
+
.catch((e) => {
|
|
261
|
+
err(`ERROR: ${e?.message || e}`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
});
|
|
264
|
+
}
|
package/bin/gramatr.ts
CHANGED
|
@@ -301,6 +301,15 @@ function main(): void {
|
|
|
301
301
|
installTarget(target.id);
|
|
302
302
|
}
|
|
303
303
|
return;
|
|
304
|
+
case 'login':
|
|
305
|
+
runTs(join(binDir, 'gmtr-login.ts'), forwardedFlags);
|
|
306
|
+
return;
|
|
307
|
+
case 'add-api-key':
|
|
308
|
+
runTs(join(binDir, 'add-api-key.ts'), raw.slice(1));
|
|
309
|
+
return;
|
|
310
|
+
case 'logout':
|
|
311
|
+
runTs(join(binDir, 'logout.ts'), raw.slice(1));
|
|
312
|
+
return;
|
|
304
313
|
case 'detect':
|
|
305
314
|
renderDetections();
|
|
306
315
|
return;
|
|
@@ -320,6 +329,9 @@ function main(): void {
|
|
|
320
329
|
log('');
|
|
321
330
|
log('Commands:');
|
|
322
331
|
log(' install [target] Install gramatr (claude-code, codex, gemini-cli, all)');
|
|
332
|
+
log(' login Authenticate with the gramatr server (OAuth)');
|
|
333
|
+
log(' add-api-key Add an API key explicitly (interactive / piped / --from-env)');
|
|
334
|
+
log(' logout Clear stored credentials (~/.gmtr.json)');
|
|
323
335
|
log(' detect Show detected CLI platforms');
|
|
324
336
|
log(' doctor Check installation health');
|
|
325
337
|
log(' upgrade Upgrade all installed targets');
|
package/bin/install.ts
CHANGED
|
@@ -15,10 +15,11 @@ import {
|
|
|
15
15
|
readdirSync, statSync, chmodSync, rmSync,
|
|
16
16
|
} from 'fs';
|
|
17
17
|
import { join, dirname, basename, resolve } from 'path';
|
|
18
|
-
import { execSync
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
19
|
import { createInterface } from 'readline';
|
|
20
20
|
import { buildClaudeHooksFile } from '../core/install.ts';
|
|
21
21
|
import { VERSION } from '../core/version.ts';
|
|
22
|
+
import { resolveAuthToken } from '../core/auth.ts';
|
|
22
23
|
|
|
23
24
|
// ── Constants ──
|
|
24
25
|
|
|
@@ -344,66 +345,37 @@ function installClaudeMd(): void {
|
|
|
344
345
|
|
|
345
346
|
// ── Step 3: Auth ──
|
|
346
347
|
|
|
347
|
-
// npx on Windows is shipped as `npx.cmd`. spawnSync without shell: true cannot
|
|
348
|
-
// resolve .cmd shims, so we fall back to the platform-specific binary name.
|
|
349
|
-
const NPX_BIN = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
|
350
|
-
|
|
351
348
|
async function handleAuth(legacyToken: string): Promise<{ url: string; token: string }> {
|
|
352
349
|
log('━━━ Step 3: Configuring gramatr MCP server ━━━');
|
|
353
350
|
log('');
|
|
354
351
|
|
|
355
352
|
const url = await prompt('gramatr server URL', DEFAULT_URL) || DEFAULT_URL;
|
|
356
353
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
if (existing.token) {
|
|
367
|
-
token = existing.token;
|
|
368
|
-
log('OK Found existing auth token in ~/.gmtr.json');
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
if (!token && legacyToken) {
|
|
373
|
-
token = legacyToken;
|
|
374
|
-
log('OK Reusing auth token from legacy aios installation');
|
|
354
|
+
// Legacy aios token migration: if we cherry-picked a token from a prior
|
|
355
|
+
// aios install and there is no current ~/.gmtr.json token, seed it so the
|
|
356
|
+
// shared resolver picks it up.
|
|
357
|
+
if (legacyToken && !existsSync(GMTR_JSON)) {
|
|
358
|
+
try {
|
|
359
|
+
writeFileSync(GMTR_JSON, `${JSON.stringify({ token: legacyToken }, null, 2)}\n`, 'utf8');
|
|
360
|
+
try { chmodSync(GMTR_JSON, 0o600); } catch { /* ok */ }
|
|
361
|
+
log('OK Seeded ~/.gmtr.json from legacy aios installation token');
|
|
362
|
+
} catch { /* ignore */ }
|
|
375
363
|
}
|
|
376
364
|
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const result = spawnSync(NPX_BIN, ['tsx', loginScript], {
|
|
388
|
-
stdio: 'inherit',
|
|
389
|
-
env: { ...process.env },
|
|
390
|
-
});
|
|
391
|
-
void result;
|
|
392
|
-
|
|
393
|
-
// Re-read token after login
|
|
394
|
-
if (existsSync(GMTR_JSON)) {
|
|
395
|
-
const data = readJson(GMTR_JSON);
|
|
396
|
-
if (data.token) token = data.token;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
if (!token) {
|
|
401
|
-
log(' Authentication skipped — run /gmtr-login later to authenticate');
|
|
402
|
-
}
|
|
365
|
+
// OAuth-first via shared helper (issue #484). The helper handles env vars,
|
|
366
|
+
// stored tokens, and spawning gmtr-login.ts when interactive. It throws a
|
|
367
|
+
// clean actionable error in headless environments.
|
|
368
|
+
let token = '';
|
|
369
|
+
try {
|
|
370
|
+
token = await resolveAuthToken({
|
|
371
|
+
interactive: isInteractive,
|
|
372
|
+
installerLabel: 'Claude Code',
|
|
373
|
+
});
|
|
374
|
+
} catch (e: any) {
|
|
403
375
|
log('');
|
|
404
|
-
|
|
376
|
+
log(e?.message || String(e));
|
|
405
377
|
log('');
|
|
406
|
-
log('
|
|
378
|
+
log('Authentication skipped — run `gramatr login` later to authenticate');
|
|
407
379
|
log('');
|
|
408
380
|
}
|
|
409
381
|
|
package/bin/logout.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gramatr logout — Clear stored gramatr credentials (issue #484).
|
|
4
|
+
*
|
|
5
|
+
* Removes ~/.gmtr.json. With --keep-backup, renames it to
|
|
6
|
+
* ~/.gmtr.json.bak.<timestamp> instead of deleting.
|
|
7
|
+
*
|
|
8
|
+
* Not-logged-in is not an error: exits 0 with a clean message.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, renameSync, unlinkSync } from "fs";
|
|
12
|
+
import { homedir } from "os";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
|
|
15
|
+
function gmtrJsonPath(): string {
|
|
16
|
+
return join(process.env.HOME || process.env.USERPROFILE || homedir(), ".gmtr.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function log(msg: string = ""): void {
|
|
20
|
+
process.stdout.write(`${msg}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function parseArgs(argv: string[]): { keepBackup: boolean; help: boolean } {
|
|
24
|
+
let keepBackup = false;
|
|
25
|
+
let help = false;
|
|
26
|
+
for (const a of argv) {
|
|
27
|
+
if (a === "--keep-backup") keepBackup = true;
|
|
28
|
+
else if (a === "--help" || a === "-h") help = true;
|
|
29
|
+
}
|
|
30
|
+
return { keepBackup, help };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function showHelp(): void {
|
|
34
|
+
log(`gramatr logout — Clear stored gramatr credentials
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
gramatr logout Delete ~/.gmtr.json
|
|
38
|
+
gramatr logout --keep-backup Rename to ~/.gmtr.json.bak.<timestamp> instead`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
42
|
+
const opts = parseArgs(argv);
|
|
43
|
+
if (opts.help) {
|
|
44
|
+
showHelp();
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!existsSync(gmtrJsonPath())) {
|
|
49
|
+
log("Not logged in.");
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (opts.keepBackup) {
|
|
54
|
+
const backup = `${gmtrJsonPath()}.bak.${Date.now()}`;
|
|
55
|
+
renameSync(gmtrJsonPath(), backup);
|
|
56
|
+
log(`Logged out. Token moved to ${backup}.`);
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
unlinkSync(gmtrJsonPath());
|
|
61
|
+
log(`Logged out. Token removed from ${gmtrJsonPath()}.`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isDirect = (() => {
|
|
66
|
+
try {
|
|
67
|
+
const invoked = process.argv[1] || "";
|
|
68
|
+
return invoked.endsWith("logout.ts") || invoked.endsWith("logout.js");
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
|
|
74
|
+
if (isDirect) {
|
|
75
|
+
process.exit(main());
|
|
76
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# gramatr - ChatGPT Desktop Integration
|
|
2
|
+
|
|
3
|
+
Tier 3 integration: MCP only (no hooks, no status line).
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
- **ChatGPT Plus, Team, or Enterprise** subscription (free accounts do not support MCP)
|
|
8
|
+
- **ChatGPT Desktop** app installed ([download](https://openai.com/chatgpt/desktop))
|
|
9
|
+
- **Developer Mode** enabled in ChatGPT Desktop settings
|
|
10
|
+
- A **gramatr API key** — get one at [gramatr.com/settings](https://gramatr.com/settings)
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
### Method 1: Installer script (recommended)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
cd packages/client
|
|
18
|
+
bun chatgpt/install.ts
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The installer will:
|
|
22
|
+
1. Find your API key from `~/.gmtr.json`, `GRAMATR_API_KEY` env, or prompt you
|
|
23
|
+
2. Validate connectivity to the gramatr server
|
|
24
|
+
3. Detect your platform and locate the ChatGPT config file
|
|
25
|
+
4. Merge the gramatr MCP server entry without overwriting existing servers
|
|
26
|
+
5. Print verification instructions
|
|
27
|
+
|
|
28
|
+
### Method 2: Environment variable
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
GRAMATR_API_KEY=your-key-here bun chatgpt/install.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Method 3: Manual configuration
|
|
35
|
+
|
|
36
|
+
Add to your ChatGPT MCP config file:
|
|
37
|
+
|
|
38
|
+
**macOS:** `~/.chatgpt/mcp.json`
|
|
39
|
+
**Windows:** `%APPDATA%\ChatGPT\mcp.json`
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"gramatr": {
|
|
45
|
+
"url": "https://mcp.gramatr.com/mcp",
|
|
46
|
+
"headers": {
|
|
47
|
+
"Authorization": "Bearer YOUR_API_KEY"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Verification
|
|
55
|
+
|
|
56
|
+
1. Open ChatGPT Desktop
|
|
57
|
+
2. Go to **Settings > Developer > MCP Servers**
|
|
58
|
+
3. Confirm "gramatr" appears in the list with a green status indicator
|
|
59
|
+
4. Start a new conversation and type: "What gramatr tools are available?"
|
|
60
|
+
5. ChatGPT should list the available MCP tools from the gramatr server
|
|
61
|
+
|
|
62
|
+
## Config file locations
|
|
63
|
+
|
|
64
|
+
| Platform | Path |
|
|
65
|
+
|----------|------|
|
|
66
|
+
| macOS | `~/.chatgpt/mcp.json` |
|
|
67
|
+
| Windows | `%APPDATA%\ChatGPT\mcp.json` |
|
|
68
|
+
|
|
69
|
+
## Limitations
|
|
70
|
+
|
|
71
|
+
ChatGPT Desktop is a **Tier 3** integration:
|
|
72
|
+
|
|
73
|
+
- MCP tools are available (search, create entities, route requests, etc.)
|
|
74
|
+
- No PostToolUse hooks (no automatic metrics tracking)
|
|
75
|
+
- No status line
|
|
76
|
+
- No prompt enrichment hooks
|
|
77
|
+
|
|
78
|
+
For the full gramatr experience with hooks, status line, and prompt enrichment, use Claude Code or Codex.
|
|
79
|
+
|
|
80
|
+
## Troubleshooting
|
|
81
|
+
|
|
82
|
+
**"gramatr" not showing in MCP servers:**
|
|
83
|
+
- Ensure Developer Mode is enabled in ChatGPT Desktop settings
|
|
84
|
+
- Check that the config file exists at the correct path
|
|
85
|
+
- Restart ChatGPT Desktop after editing the config
|
|
86
|
+
|
|
87
|
+
**Connection errors:**
|
|
88
|
+
- Verify your API key is valid: `bun bin/gmtr-login.ts --status`
|
|
89
|
+
- Check server health: `curl https://api.gramatr.com/health`
|
|
90
|
+
- Ensure you have an active internet connection
|
|
91
|
+
|
|
92
|
+
**Tools not appearing in conversation:**
|
|
93
|
+
- MCP tools may take a moment to load after connecting
|
|
94
|
+
- Try starting a new conversation
|
|
95
|
+
- Check the MCP server status indicator in Settings > Developer
|