gramatr 0.3.57 → 0.3.59
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/gmtr-login.ts +64 -28
- package/bin/gramatr.ts +16 -2
- package/bin/install.ts +39 -53
- package/bin/logout.ts +76 -0
- package/chatgpt/install.ts +8 -65
- package/core/auth.ts +170 -0
- package/desktop/build-mcpb.ts +1 -2
- package/desktop/install.ts +8 -65
- package/hooks/GMTRPromptEnricher.hook.ts +2 -1
- package/hooks/GMTRRatingCapture.hook.ts +1 -1
- package/hooks/lib/gmtr-hook-utils.ts +1 -2
- package/hooks/lib/notify.ts +1 -1
- package/hooks/session-end.hook.ts +1 -1
- package/package.json +2 -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/gmtr-login.ts
CHANGED
|
@@ -496,37 +496,54 @@ async function loginBrowser(): Promise<void> {
|
|
|
496
496
|
}
|
|
497
497
|
|
|
498
498
|
// ── CLI ──
|
|
499
|
+
//
|
|
500
|
+
// Defense in depth (Fix A' for Windows top-level await crash):
|
|
501
|
+
// All CLI code is wrapped in an async `main()` and invoked via a
|
|
502
|
+
// module-run guard. This keeps the module importable without firing
|
|
503
|
+
// side effects and avoids top-level await entirely, so the file stays
|
|
504
|
+
// safe even if the package ever loses `"type": "module"` or tsx
|
|
505
|
+
// changes its default target to CJS.
|
|
506
|
+
|
|
507
|
+
export async function main(): Promise<void> {
|
|
508
|
+
const args = process.argv.slice(2);
|
|
509
|
+
|
|
510
|
+
if (args.includes('--status') || args.includes('status')) {
|
|
511
|
+
await showStatus();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
499
514
|
|
|
500
|
-
|
|
515
|
+
if (args.includes('--logout') || args.includes('logout')) {
|
|
516
|
+
await logout();
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
501
519
|
|
|
502
|
-
if (args.includes('--
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
520
|
+
if (args.includes('--token') || args.includes('-t')) {
|
|
521
|
+
const tokenIdx = args.indexOf('--token') !== -1 ? args.indexOf('--token') : args.indexOf('-t');
|
|
522
|
+
const token = args[tokenIdx + 1];
|
|
523
|
+
if (!token) {
|
|
524
|
+
// Interactive paste mode — like Claude's login
|
|
525
|
+
console.log('\n Paste your gramatr token below.');
|
|
526
|
+
console.log(' (API keys start with aios_sk_ or gmtr_sk_)\n');
|
|
527
|
+
process.stdout.write(' Token: ');
|
|
528
|
+
|
|
529
|
+
const { createInterface } = await import('readline');
|
|
530
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
531
|
+
const pastedToken = await new Promise<string>((resolve) => {
|
|
532
|
+
rl.on('line', (line: string) => { rl.close(); resolve(line.trim()); });
|
|
533
|
+
});
|
|
534
|
+
if (!pastedToken) {
|
|
535
|
+
console.log(' No token provided.\n');
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
await loginWithToken(pastedToken);
|
|
539
|
+
} else {
|
|
540
|
+
await loginWithToken(token);
|
|
523
541
|
}
|
|
524
|
-
|
|
525
|
-
} else {
|
|
526
|
-
await loginWithToken(token);
|
|
542
|
+
return;
|
|
527
543
|
}
|
|
528
|
-
|
|
529
|
-
|
|
544
|
+
|
|
545
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
546
|
+
console.log(`
|
|
530
547
|
gmtr-login — Authenticate with the gramatr server
|
|
531
548
|
|
|
532
549
|
Usage:
|
|
@@ -541,7 +558,26 @@ if (args.includes('--status') || args.includes('status')) {
|
|
|
541
558
|
Server: ${SERVER_BASE}
|
|
542
559
|
Dashboard: ${DASHBOARD_BASE}
|
|
543
560
|
`);
|
|
544
|
-
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
|
|
545
564
|
// Default: browser login flow
|
|
546
565
|
await loginBrowser();
|
|
547
566
|
}
|
|
567
|
+
|
|
568
|
+
// Module-run guard. Works both when invoked directly via
|
|
569
|
+
// `tsx bin/gmtr-login.ts` and when imported from another module
|
|
570
|
+
// (tests, programmatic use). Under ESM, import.meta.url is the
|
|
571
|
+
// canonical check; we also accept a path-suffix match as a belt.
|
|
572
|
+
const invokedAs = process.argv[1] || '';
|
|
573
|
+
const isMain =
|
|
574
|
+
import.meta.url === `file://${invokedAs}` ||
|
|
575
|
+
invokedAs.endsWith('gmtr-login.ts') ||
|
|
576
|
+
invokedAs.endsWith('gmtr-login.js');
|
|
577
|
+
|
|
578
|
+
if (isMain) {
|
|
579
|
+
main().catch((err) => {
|
|
580
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
581
|
+
process.exit(1);
|
|
582
|
+
});
|
|
583
|
+
}
|
package/bin/gramatr.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { spawnSync } from 'child_process';
|
|
4
4
|
import { existsSync } from 'fs';
|
|
5
|
+
import { createRequire } from 'module';
|
|
5
6
|
import { homedir } from 'os';
|
|
6
7
|
import { dirname, join } from 'path';
|
|
7
8
|
import { fileURLToPath } from 'url';
|
|
@@ -47,8 +48,9 @@ function runTs(script: string, extraArgs: string[] = []): void {
|
|
|
47
48
|
// Resolve tsx from this package's node_modules (not CWD) so `npx tsx` works
|
|
48
49
|
// even on hosts where the user hasn't globally installed tsx.
|
|
49
50
|
try {
|
|
50
|
-
|
|
51
|
-
const
|
|
51
|
+
// ESM-safe: createRequire gives us require.resolve for finding tsx on disk.
|
|
52
|
+
const req = createRequire(import.meta.url);
|
|
53
|
+
const tsxCli = join(dirname(req.resolve('tsx/package.json')), 'dist', 'cli.mjs');
|
|
52
54
|
run(process.execPath, [tsxCli, script, ...extraArgs]);
|
|
53
55
|
} catch {
|
|
54
56
|
// Fallback: global npx tsx
|
|
@@ -301,6 +303,15 @@ function main(): void {
|
|
|
301
303
|
installTarget(target.id);
|
|
302
304
|
}
|
|
303
305
|
return;
|
|
306
|
+
case 'login':
|
|
307
|
+
runTs(join(binDir, 'gmtr-login.ts'), forwardedFlags);
|
|
308
|
+
return;
|
|
309
|
+
case 'add-api-key':
|
|
310
|
+
runTs(join(binDir, 'add-api-key.ts'), raw.slice(1));
|
|
311
|
+
return;
|
|
312
|
+
case 'logout':
|
|
313
|
+
runTs(join(binDir, 'logout.ts'), raw.slice(1));
|
|
314
|
+
return;
|
|
304
315
|
case 'detect':
|
|
305
316
|
renderDetections();
|
|
306
317
|
return;
|
|
@@ -320,6 +331,9 @@ function main(): void {
|
|
|
320
331
|
log('');
|
|
321
332
|
log('Commands:');
|
|
322
333
|
log(' install [target] Install gramatr (claude-code, codex, gemini-cli, all)');
|
|
334
|
+
log(' login Authenticate with the gramatr server (OAuth)');
|
|
335
|
+
log(' add-api-key Add an API key explicitly (interactive / piped / --from-env)');
|
|
336
|
+
log(' logout Clear stored credentials (~/.gmtr.json)');
|
|
323
337
|
log(' detect Show detected CLI platforms');
|
|
324
338
|
log(' doctor Check installation health');
|
|
325
339
|
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
|
|
|
@@ -122,9 +123,23 @@ function dirSize(dir: string): string {
|
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
function which(cmd: string): string | null {
|
|
126
|
+
// Platform-aware: `command -v` is a POSIX shell builtin that does not exist
|
|
127
|
+
// on Windows cmd.exe / PowerShell. Use `where` on Windows.
|
|
128
|
+
// stdio: ['ignore', 'pipe', 'ignore'] suppresses the "not found" stderr
|
|
129
|
+
// noise that previously leaked to the user's terminal during install.
|
|
130
|
+
// Closes #483 Bug 1.
|
|
131
|
+
const probe = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
|
|
125
132
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
133
|
+
const out = execSync(probe, {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
136
|
+
});
|
|
137
|
+
// `where` on Windows returns multiple lines when the binary is on PATH
|
|
138
|
+
// multiple times; take the first match.
|
|
139
|
+
return out.trim().split(/\r?\n/)[0] || null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
128
143
|
}
|
|
129
144
|
|
|
130
145
|
function copyFileIfExists(src: string, dest: string, executable = false): boolean {
|
|
@@ -344,66 +359,37 @@ function installClaudeMd(): void {
|
|
|
344
359
|
|
|
345
360
|
// ── Step 3: Auth ──
|
|
346
361
|
|
|
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
362
|
async function handleAuth(legacyToken: string): Promise<{ url: string; token: string }> {
|
|
352
363
|
log('━━━ Step 3: Configuring gramatr MCP server ━━━');
|
|
353
364
|
log('');
|
|
354
365
|
|
|
355
366
|
const url = await prompt('gramatr server URL', DEFAULT_URL) || DEFAULT_URL;
|
|
356
367
|
|
|
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');
|
|
368
|
+
// Legacy aios token migration: if we cherry-picked a token from a prior
|
|
369
|
+
// aios install and there is no current ~/.gmtr.json token, seed it so the
|
|
370
|
+
// shared resolver picks it up.
|
|
371
|
+
if (legacyToken && !existsSync(GMTR_JSON)) {
|
|
372
|
+
try {
|
|
373
|
+
writeFileSync(GMTR_JSON, `${JSON.stringify({ token: legacyToken }, null, 2)}\n`, 'utf8');
|
|
374
|
+
try { chmodSync(GMTR_JSON, 0o600); } catch { /* ok */ }
|
|
375
|
+
log('OK Seeded ~/.gmtr.json from legacy aios installation token');
|
|
376
|
+
} catch { /* ignore */ }
|
|
375
377
|
}
|
|
376
378
|
|
|
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
|
-
}
|
|
379
|
+
// OAuth-first via shared helper (issue #484). The helper handles env vars,
|
|
380
|
+
// stored tokens, and spawning gmtr-login.ts when interactive. It throws a
|
|
381
|
+
// clean actionable error in headless environments.
|
|
382
|
+
let token = '';
|
|
383
|
+
try {
|
|
384
|
+
token = await resolveAuthToken({
|
|
385
|
+
interactive: isInteractive,
|
|
386
|
+
installerLabel: 'Claude Code',
|
|
387
|
+
});
|
|
388
|
+
} catch (e: any) {
|
|
403
389
|
log('');
|
|
404
|
-
|
|
390
|
+
log(e?.message || String(e));
|
|
405
391
|
log('');
|
|
406
|
-
log('
|
|
392
|
+
log('Authentication skipped — run `npx gramatr@latest login` later to authenticate');
|
|
407
393
|
log('');
|
|
408
394
|
}
|
|
409
395
|
|
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
|
+
}
|
package/chatgpt/install.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import {
|
|
7
7
|
getChatGPTConfigPath,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildMcpServerEntry,
|
|
10
10
|
type ChatGPTConfig,
|
|
11
11
|
} from './lib/chatgpt-install-utils.ts';
|
|
12
|
+
import { resolveAuthToken } from '../core/auth.ts';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_MCP_URL = 'https://mcp.gramatr.com/mcp';
|
|
14
15
|
const VALIDATION_ENDPOINT = 'https://api.gramatr.com/health';
|
|
@@ -26,43 +27,6 @@ function readJsonFile<T>(path: string, fallback: T): T {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
* Resolve API key from available sources.
|
|
31
|
-
* Priority: CLI arg > GRAMATR_API_KEY env > ~/.gmtr.json token > gmtr-client/settings.json
|
|
32
|
-
*/
|
|
33
|
-
function resolveApiKey(): string | null {
|
|
34
|
-
const home = homedir();
|
|
35
|
-
|
|
36
|
-
// 1. GRAMATR_API_KEY env var
|
|
37
|
-
if (process.env.GRAMATR_API_KEY) return process.env.GRAMATR_API_KEY;
|
|
38
|
-
|
|
39
|
-
// 2. GMTR_TOKEN env var (legacy compat)
|
|
40
|
-
if (process.env.GMTR_TOKEN) return process.env.GMTR_TOKEN;
|
|
41
|
-
|
|
42
|
-
// 3. ~/.gmtr.json
|
|
43
|
-
try {
|
|
44
|
-
const gmtrJsonPath = join(home, '.gmtr.json');
|
|
45
|
-
const gmtrJson = JSON.parse(readFileSync(gmtrJsonPath, 'utf8'));
|
|
46
|
-
if (gmtrJson.token) return gmtrJson.token;
|
|
47
|
-
} catch {
|
|
48
|
-
// Not found or parse error
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 4. ~/gmtr-client/settings.json
|
|
52
|
-
try {
|
|
53
|
-
const gmtrDir = process.env.GMTR_DIR || join(home, 'gmtr-client');
|
|
54
|
-
const settingsPath = join(gmtrDir, 'settings.json');
|
|
55
|
-
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
56
|
-
if (settings.auth?.api_key && settings.auth.api_key !== 'REPLACE_WITH_YOUR_API_KEY') {
|
|
57
|
-
return settings.auth.api_key;
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// Not found or parse error
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
30
|
/**
|
|
67
31
|
* Validate token against gramatr server health endpoint.
|
|
68
32
|
* Returns true if server is reachable (we don't enforce auth for install — server validates on use).
|
|
@@ -80,18 +44,6 @@ async function validateServer(serverUrl: string): Promise<boolean> {
|
|
|
80
44
|
}
|
|
81
45
|
}
|
|
82
46
|
|
|
83
|
-
async function promptForInput(prompt: string): Promise<string> {
|
|
84
|
-
process.stdout.write(prompt);
|
|
85
|
-
const reader = process.stdin;
|
|
86
|
-
reader.resume();
|
|
87
|
-
return new Promise((resolve) => {
|
|
88
|
-
reader.once('data', (data) => {
|
|
89
|
-
reader.pause();
|
|
90
|
-
resolve(data.toString().trim());
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
47
|
async function main(): Promise<void> {
|
|
96
48
|
const home = homedir();
|
|
97
49
|
const platform = process.platform;
|
|
@@ -101,22 +53,13 @@ async function main(): Promise<void> {
|
|
|
101
53
|
log('====================================');
|
|
102
54
|
log('');
|
|
103
55
|
|
|
104
|
-
// Step 1: Resolve auth
|
|
56
|
+
// Step 1: Resolve auth (OAuth-first via shared helper — issue #484)
|
|
105
57
|
log('Step 1: Resolving authentication...');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (!apiKey) {
|
|
112
|
-
log('');
|
|
113
|
-
log('ERROR: API key is required. Get one at https://gramatr.com/settings');
|
|
114
|
-
log(' Or set GRAMATR_API_KEY environment variable before running this installer.');
|
|
115
|
-
process.exit(1);
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
log(' OK Found existing API key');
|
|
119
|
-
}
|
|
58
|
+
const apiKey = await resolveAuthToken({
|
|
59
|
+
interactive: true,
|
|
60
|
+
installerLabel: 'ChatGPT Desktop',
|
|
61
|
+
});
|
|
62
|
+
log(' OK Authenticated');
|
|
120
63
|
|
|
121
64
|
// Step 2: Validate server connectivity
|
|
122
65
|
log('');
|
package/core/auth.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared installer auth helper — OAuth-first credential resolution.
|
|
3
|
+
*
|
|
4
|
+
* Issue #484: Eliminates the paste-API-key prompt from installer flows.
|
|
5
|
+
* The only interactive auth path is OAuth via gmtr-login.ts. API key
|
|
6
|
+
* management is handled by the explicit `gramatr add-api-key` subcommand.
|
|
7
|
+
*
|
|
8
|
+
* Resolution chain (first non-empty wins):
|
|
9
|
+
* 1. GRAMATR_API_KEY env var
|
|
10
|
+
* 2. GMTR_TOKEN env var (legacy)
|
|
11
|
+
* 3. ~/.gmtr.json `token` field
|
|
12
|
+
* 4. ~/gmtr-client/settings.json `auth.api_key` (legacy, skips placeholder)
|
|
13
|
+
* 5. If interactive + TTY: spawn gmtr-login.ts (OAuth)
|
|
14
|
+
* 6. Otherwise: throw clean actionable error
|
|
15
|
+
*
|
|
16
|
+
* This helper NEVER prompts for paste. If you need to add an API key,
|
|
17
|
+
* use `gramatr add-api-key` (interactive / piped / --from-env).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "child_process";
|
|
21
|
+
import { existsSync, readFileSync } from "fs";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
import { dirname, join } from "path";
|
|
24
|
+
import { fileURLToPath } from "url";
|
|
25
|
+
|
|
26
|
+
export interface ResolveAuthTokenOptions {
|
|
27
|
+
interactive: boolean;
|
|
28
|
+
installerLabel: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const PLACEHOLDER_KEY = "REPLACE_WITH_YOUR_API_KEY";
|
|
32
|
+
|
|
33
|
+
function readJsonSafe(path: string): Record<string, any> | null {
|
|
34
|
+
if (!existsSync(path)) return null;
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getHome(): string {
|
|
43
|
+
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function gmtrJsonPath(): string {
|
|
47
|
+
return join(getHome(), ".gmtr.json");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function legacySettingsPath(): string {
|
|
51
|
+
const gmtrDir = process.env.GMTR_DIR || join(getHome(), "gmtr-client");
|
|
52
|
+
return join(gmtrDir, "settings.json");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function tokenFromEnv(): string | null {
|
|
56
|
+
if (process.env.GRAMATR_API_KEY) return process.env.GRAMATR_API_KEY;
|
|
57
|
+
if (process.env.GMTR_TOKEN) return process.env.GMTR_TOKEN;
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tokenFromGmtrJson(): string | null {
|
|
62
|
+
const data = readJsonSafe(gmtrJsonPath());
|
|
63
|
+
if (data && typeof data.token === "string" && data.token.trim()) {
|
|
64
|
+
return data.token.trim();
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tokenFromLegacySettings(): string | null {
|
|
70
|
+
const data = readJsonSafe(legacySettingsPath());
|
|
71
|
+
if (!data) return null;
|
|
72
|
+
const key = data.auth?.api_key;
|
|
73
|
+
if (typeof key === "string" && key && key !== PLACEHOLDER_KEY) {
|
|
74
|
+
return key;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function findGmtrLoginScript(): string | null {
|
|
80
|
+
// Resolve gmtr-login.ts relative to this file. In source layout it's at
|
|
81
|
+
// ../bin/gmtr-login.ts; in installed layout the same relative path holds.
|
|
82
|
+
try {
|
|
83
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
const candidate = join(here, "..", "bin", "gmtr-login.ts");
|
|
85
|
+
if (existsSync(candidate)) return candidate;
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
// Fallback to installed client dir
|
|
90
|
+
const installedCandidate = join(
|
|
91
|
+
process.env.GMTR_DIR || join(getHome(), "gmtr-client"),
|
|
92
|
+
"bin",
|
|
93
|
+
"gmtr-login.ts",
|
|
94
|
+
);
|
|
95
|
+
if (existsSync(installedCandidate)) return installedCandidate;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function spawnOAuthLogin(): { ok: boolean; reason?: string } {
|
|
100
|
+
const script = findGmtrLoginScript();
|
|
101
|
+
if (!script) {
|
|
102
|
+
return { ok: false, reason: "gmtr-login.ts not found on disk" };
|
|
103
|
+
}
|
|
104
|
+
// Match the existing handleAuth() pattern in bin/install.ts:383-401 —
|
|
105
|
+
// npx tsx with inherited stdio so the browser-open message reaches
|
|
106
|
+
// the user and stdin works correctly.
|
|
107
|
+
const npxBin = process.platform === "win32" ? "npx.cmd" : "npx";
|
|
108
|
+
const result = spawnSync(npxBin, ["tsx", script], {
|
|
109
|
+
stdio: "inherit",
|
|
110
|
+
env: { ...process.env },
|
|
111
|
+
});
|
|
112
|
+
if (result.error) {
|
|
113
|
+
return { ok: false, reason: result.error.message };
|
|
114
|
+
}
|
|
115
|
+
if (typeof result.status === "number" && result.status !== 0) {
|
|
116
|
+
return { ok: false, reason: `gmtr-login exited with code ${result.status}` };
|
|
117
|
+
}
|
|
118
|
+
return { ok: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const HEADLESS_ERROR =
|
|
122
|
+
"No gramatr credentials found. Set one of:\n" +
|
|
123
|
+
" - GRAMATR_API_KEY environment variable\n" +
|
|
124
|
+
" - Run: npx gramatr@latest login (interactive, recommended)\n" +
|
|
125
|
+
" - Run: npx gramatr@latest add-api-key (for headless / CI use)\n" +
|
|
126
|
+
"Then re-run the install.";
|
|
127
|
+
|
|
128
|
+
const OAUTH_FAILED_ERROR =
|
|
129
|
+
'OAuth login failed. Run "npx gramatr@latest login" to retry, ' +
|
|
130
|
+
'or "npx gramatr@latest add-api-key" to use an API key instead.';
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a gramatr auth token, OAuth-first.
|
|
134
|
+
*
|
|
135
|
+
* Never prompts for an API key paste. If interactive and no token is
|
|
136
|
+
* stored, spawns gmtr-login.ts to run the OAuth flow. If headless or
|
|
137
|
+
* non-interactive, throws an actionable error pointing the user at the
|
|
138
|
+
* explicit `gramatr login` and `gramatr add-api-key` commands.
|
|
139
|
+
*/
|
|
140
|
+
export async function resolveAuthToken(opts: ResolveAuthTokenOptions): Promise<string> {
|
|
141
|
+
// 1 + 2: env vars
|
|
142
|
+
const envToken = tokenFromEnv();
|
|
143
|
+
if (envToken) return envToken;
|
|
144
|
+
|
|
145
|
+
// 3: ~/.gmtr.json
|
|
146
|
+
const stored = tokenFromGmtrJson();
|
|
147
|
+
if (stored) return stored;
|
|
148
|
+
|
|
149
|
+
// 4: legacy settings.json
|
|
150
|
+
const legacy = tokenFromLegacySettings();
|
|
151
|
+
if (legacy) return legacy;
|
|
152
|
+
|
|
153
|
+
// 5: spawn OAuth if interactive + TTY
|
|
154
|
+
const hasTty = Boolean(process.stdin.isTTY);
|
|
155
|
+
if (opts.interactive && hasTty) {
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
`[${opts.installerLabel}] No gramatr credentials found. Starting OAuth login...\n`,
|
|
158
|
+
);
|
|
159
|
+
const result = spawnOAuthLogin();
|
|
160
|
+
if (!result.ok) {
|
|
161
|
+
throw new Error(OAUTH_FAILED_ERROR);
|
|
162
|
+
}
|
|
163
|
+
const after = tokenFromGmtrJson();
|
|
164
|
+
if (after) return after;
|
|
165
|
+
throw new Error("OAuth completed but no token was stored");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 6: headless / non-interactive — clean actionable error
|
|
169
|
+
throw new Error(HEADLESS_ERROR);
|
|
170
|
+
}
|
package/desktop/build-mcpb.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* Usage: bun desktop/build-mcpb.ts [--out <path>]
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
16
16
|
import { join, dirname } from 'path';
|
|
17
17
|
import { fileURLToPath } from 'url';
|
|
18
18
|
|
|
@@ -65,7 +65,6 @@ interface McpbManifest {
|
|
|
65
65
|
function readPackageVersion(): string {
|
|
66
66
|
try {
|
|
67
67
|
const pkgPath = join(clientDir, 'package.json');
|
|
68
|
-
const { readFileSync } = require('fs');
|
|
69
68
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
70
69
|
return pkg.version || '0.0.0';
|
|
71
70
|
} catch {
|
package/desktop/install.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from 'path';
|
|
5
5
|
import { homedir } from 'os';
|
|
6
6
|
import {
|
|
7
7
|
getDesktopConfigPath,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildMcpServerEntry,
|
|
10
10
|
type DesktopConfig,
|
|
11
11
|
} from './lib/desktop-install-utils.ts';
|
|
12
|
+
import { resolveAuthToken } from '../core/auth.ts';
|
|
12
13
|
|
|
13
14
|
const DEFAULT_MCP_URL = 'https://mcp.gramatr.com/mcp';
|
|
14
15
|
const VALIDATION_ENDPOINT = 'https://api.gramatr.com/health';
|
|
@@ -26,43 +27,6 @@ function readJsonFile<T>(path: string, fallback: T): T {
|
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
/**
|
|
30
|
-
* Resolve API key from available sources.
|
|
31
|
-
* Priority: CLI arg > GRAMATR_API_KEY env > ~/.gmtr.json token > gmtr-client/settings.json
|
|
32
|
-
*/
|
|
33
|
-
function resolveApiKey(): string | null {
|
|
34
|
-
const home = homedir();
|
|
35
|
-
|
|
36
|
-
// 1. GRAMATR_API_KEY env var
|
|
37
|
-
if (process.env.GRAMATR_API_KEY) return process.env.GRAMATR_API_KEY;
|
|
38
|
-
|
|
39
|
-
// 2. GMTR_TOKEN env var (legacy compat)
|
|
40
|
-
if (process.env.GMTR_TOKEN) return process.env.GMTR_TOKEN;
|
|
41
|
-
|
|
42
|
-
// 3. ~/.gmtr.json
|
|
43
|
-
try {
|
|
44
|
-
const gmtrJsonPath = join(home, '.gmtr.json');
|
|
45
|
-
const gmtrJson = JSON.parse(readFileSync(gmtrJsonPath, 'utf8'));
|
|
46
|
-
if (gmtrJson.token) return gmtrJson.token;
|
|
47
|
-
} catch {
|
|
48
|
-
// Not found or parse error
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// 4. ~/gmtr-client/settings.json
|
|
52
|
-
try {
|
|
53
|
-
const gmtrDir = process.env.GMTR_DIR || join(home, 'gmtr-client');
|
|
54
|
-
const settingsPath = join(gmtrDir, 'settings.json');
|
|
55
|
-
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
56
|
-
if (settings.auth?.api_key && settings.auth.api_key !== 'REPLACE_WITH_YOUR_API_KEY') {
|
|
57
|
-
return settings.auth.api_key;
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// Not found or parse error
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
30
|
/**
|
|
67
31
|
* Validate token against gramatr server health endpoint.
|
|
68
32
|
* Returns true if server is reachable (we don't enforce auth for install — server validates on use).
|
|
@@ -80,18 +44,6 @@ async function validateServer(serverUrl: string): Promise<boolean> {
|
|
|
80
44
|
}
|
|
81
45
|
}
|
|
82
46
|
|
|
83
|
-
async function promptForInput(prompt: string): Promise<string> {
|
|
84
|
-
process.stdout.write(prompt);
|
|
85
|
-
const reader = process.stdin;
|
|
86
|
-
reader.resume();
|
|
87
|
-
return new Promise((resolve) => {
|
|
88
|
-
reader.once('data', (data) => {
|
|
89
|
-
reader.pause();
|
|
90
|
-
resolve(data.toString().trim());
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
47
|
async function main(): Promise<void> {
|
|
96
48
|
const home = homedir();
|
|
97
49
|
const platform = process.platform;
|
|
@@ -101,22 +53,13 @@ async function main(): Promise<void> {
|
|
|
101
53
|
log('===================================');
|
|
102
54
|
log('');
|
|
103
55
|
|
|
104
|
-
// Step 1: Resolve auth
|
|
56
|
+
// Step 1: Resolve auth (OAuth-first via shared helper — issue #484)
|
|
105
57
|
log('Step 1: Resolving authentication...');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (!apiKey) {
|
|
112
|
-
log('');
|
|
113
|
-
log('ERROR: API key is required. Get one at https://gramatr.com/settings');
|
|
114
|
-
log(' Or set GRAMATR_API_KEY environment variable before running this installer.');
|
|
115
|
-
process.exit(1);
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
log(' OK Found existing API key');
|
|
119
|
-
}
|
|
58
|
+
const apiKey = await resolveAuthToken({
|
|
59
|
+
interactive: true,
|
|
60
|
+
installerLabel: 'Claude Desktop',
|
|
61
|
+
});
|
|
62
|
+
log(' OK Authenticated');
|
|
120
63
|
|
|
121
64
|
// Step 2: Validate server connectivity
|
|
122
65
|
log('');
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* - Token savings metadata
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { readFileSync } from 'fs';
|
|
23
24
|
import { getGitContext } from './lib/gmtr-hook-utils.ts';
|
|
24
25
|
import {
|
|
25
26
|
persistClassificationResult,
|
|
@@ -51,7 +52,7 @@ function resolveProjectId(): string | null {
|
|
|
51
52
|
// Read from the context file written by session-start.sh
|
|
52
53
|
const home = process.env.HOME || process.env.USERPROFILE || '';
|
|
53
54
|
const contextPath = `${home}/.claude/current-project-context.json`;
|
|
54
|
-
const context = JSON.parse(
|
|
55
|
+
const context = JSON.parse(readFileSync(contextPath, 'utf8'));
|
|
55
56
|
const remote = context.git_remote;
|
|
56
57
|
if (!remote || remote === 'no-remote') return null;
|
|
57
58
|
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
import { appendFileSync, mkdirSync, existsSync } from 'fs';
|
|
27
|
+
import { spawn } from 'child_process';
|
|
27
28
|
import { join, dirname } from 'path';
|
|
28
29
|
import { getGmtrDir } from './lib/paths';
|
|
29
30
|
|
|
@@ -140,7 +141,6 @@ function notifyLowRating(rating: number, comment?: string): void {
|
|
|
140
141
|
const msg = comment
|
|
141
142
|
? `Rating ${rating}/10: ${comment}`
|
|
142
143
|
: `Rating ${rating}/10 received`;
|
|
143
|
-
const { spawn } = require('child_process');
|
|
144
144
|
spawn('osascript', ['-e', `display notification "${msg}" with title "gramatr" subtitle "Low Rating Alert"`], {
|
|
145
145
|
stdio: 'ignore',
|
|
146
146
|
detached: true,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { execSync } from 'child_process';
|
|
11
|
-
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs';
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, appendFileSync } from 'fs';
|
|
12
12
|
import { join, basename } from 'path';
|
|
13
13
|
|
|
14
14
|
// ── Types ──
|
|
@@ -680,7 +680,6 @@ export function appendLine(filePath: string, line: string): void {
|
|
|
680
680
|
if (dir && !existsSync(dir)) {
|
|
681
681
|
mkdirSync(dir, { recursive: true });
|
|
682
682
|
}
|
|
683
|
-
const { appendFileSync } = require('fs');
|
|
684
683
|
appendFileSync(filePath, line + '\n', 'utf8');
|
|
685
684
|
}
|
|
686
685
|
|
package/hooks/lib/notify.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { platform } from 'os';
|
|
13
|
+
import { spawn } from 'child_process';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Send a local push notification.
|
|
@@ -24,7 +25,6 @@ export function notify(subtitle: string, message: string): void {
|
|
|
24
25
|
const safeMsg = message.replace(/"/g, '\\"');
|
|
25
26
|
const safeSub = subtitle.replace(/"/g, '\\"');
|
|
26
27
|
|
|
27
|
-
const { spawn } = require('child_process');
|
|
28
28
|
spawn('osascript', ['-e',
|
|
29
29
|
`display notification "${safeMsg}" with title "gramatr" subtitle "${safeSub}"`,
|
|
30
30
|
], { stdio: 'ignore', detached: true }).unref();
|
|
@@ -94,7 +94,7 @@ async function main(): Promise<void> {
|
|
|
94
94
|
// Build session summary from git data
|
|
95
95
|
let branch = 'unknown';
|
|
96
96
|
try {
|
|
97
|
-
const { execSync } =
|
|
97
|
+
const { execSync } = await import('child_process');
|
|
98
98
|
branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
99
99
|
encoding: 'utf8',
|
|
100
100
|
stdio: ['pipe', 'pipe', 'pipe'],
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gramatr",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.59",
|
|
4
4
|
"description": "grāmatr — context engineering layer for AI coding agents. Every prompt gets a pre-computed intelligence packet: decision routing, capability audit, behavioral directives, memory pre-load, and ISC scaffolds. Continuity across sessions for Claude Code, Codex, and Gemini CLI.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "https://github.com/gramatr/gramatr.git",
|