gramatr 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +18 -0
- package/README.md +78 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +547 -0
- package/bin/gramatr.js +33 -0
- package/bin/gramatr.ts +248 -0
- package/bin/install.ts +756 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +437 -0
- package/bin/uninstall.ts +289 -0
- package/bin/version-sync.ts +46 -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 +76 -0
- package/codex/install.ts +99 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +167 -0
- package/core/install.ts +114 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +244 -0
- package/core/routing.ts +98 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +178 -0
- package/core/version.ts +2 -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 +74 -0
- package/gemini/install.ts +272 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +650 -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 +771 -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 +490 -0
- package/package.json +54 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- GMTR-START — Do not edit between these markers. Managed by gramatr installer. -->
|
|
2
|
+
# gramatr
|
|
3
|
+
|
|
4
|
+
You have gramatr installed. A hook pre-classifies every user request and injects
|
|
5
|
+
intelligence as `[GMTR Intelligence — ...]` into your context.
|
|
6
|
+
|
|
7
|
+
**Follow the intelligence packet.** It contains behavioral directives, effort level,
|
|
8
|
+
ISC scaffold, capability audit, phase templates, and composed agents.
|
|
9
|
+
|
|
10
|
+
**Memory:** Use gramatr MCP tools (`search_semantic`, `create_entity`, `add_observation`),
|
|
11
|
+
not local markdown files.
|
|
12
|
+
|
|
13
|
+
**Identity:** Read from `~/gmtr-client/settings.json` — `daidentity` for your name,
|
|
14
|
+
`principal` for the user's name.
|
|
15
|
+
|
|
16
|
+
**If the server is unreachable:** Use 7-phase structure (OBSERVE → THINK → PLAN → BUILD →
|
|
17
|
+
EXECUTE → VERIFY → LEARN). Create ISC before work. Never combine phases.
|
|
18
|
+
<!-- GMTR-END -->
|
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# grāmatr Client Package
|
|
2
|
+
|
|
3
|
+
This package contains the shared grāmatr client runtime plus per-target adapters.
|
|
4
|
+
|
|
5
|
+
Current target model:
|
|
6
|
+
|
|
7
|
+
- local / thick-client targets
|
|
8
|
+
- `claude-code`
|
|
9
|
+
- `codex`
|
|
10
|
+
- hosted / remote targets
|
|
11
|
+
- `remote-mcp`
|
|
12
|
+
- `claude-web`
|
|
13
|
+
- `chatgpt-web`
|
|
14
|
+
|
|
15
|
+
The important distinction is that hosted targets are not local hook installs. They use remote MCP or future hosted-client packaging.
|
|
16
|
+
|
|
17
|
+
## CLI
|
|
18
|
+
|
|
19
|
+
The package exposes a `gramatr` CLI:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
gramatr install
|
|
23
|
+
gramatr install claude-code
|
|
24
|
+
gramatr install codex
|
|
25
|
+
gramatr install all
|
|
26
|
+
gramatr detect
|
|
27
|
+
gramatr doctor
|
|
28
|
+
gramatr migrate
|
|
29
|
+
gramatr migrate --apply
|
|
30
|
+
gramatr upgrade
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Command Behavior
|
|
34
|
+
|
|
35
|
+
- `gramatr install`
|
|
36
|
+
- interactive local target selection
|
|
37
|
+
- installs local adapter(s)
|
|
38
|
+
- prints remote target guidance separately
|
|
39
|
+
- `gramatr detect`
|
|
40
|
+
- reports detected local targets and known hosted targets
|
|
41
|
+
- `gramatr doctor`
|
|
42
|
+
- reports current target detection, payload presence, and stale legacy artifacts
|
|
43
|
+
- `gramatr migrate`
|
|
44
|
+
- dry-run legacy cleanup and Claude config sanitization
|
|
45
|
+
- `gramatr migrate --apply`
|
|
46
|
+
- applies legacy cleanup and configuration sanitization
|
|
47
|
+
- `gramatr upgrade`
|
|
48
|
+
- performs migration cleanup when needed, then re-syncs detected local targets
|
|
49
|
+
|
|
50
|
+
## Thin-Client Default
|
|
51
|
+
|
|
52
|
+
Claude installs now default to the thin-client hook set.
|
|
53
|
+
|
|
54
|
+
Optional Claude UX hooks are disabled by default and can be enabled with:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
GMTR_ENABLE_OPTIONAL_CLAUDE_UX=1
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
Install dependencies:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pnpm install --frozen-lockfile
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Run package tests:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pnpm --filter @aios-v2/client test
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run target detection locally:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pnpm --filter @aios-v2/client exec bun bin/gramatr.ts detect
|
|
78
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { runLegacyMigration } from '../core/migration.ts';
|
|
5
|
+
|
|
6
|
+
function log(message: string): void {
|
|
7
|
+
process.stdout.write(`${message}\n`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function main(): void {
|
|
11
|
+
const home = process.env.HOME || process.env.USERPROFILE;
|
|
12
|
+
if (!home) {
|
|
13
|
+
throw new Error('HOME is not set');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const apply = process.argv.includes('--apply');
|
|
17
|
+
const includeOptionalUx = process.env.GMTR_ENABLE_OPTIONAL_CLAUDE_UX === '1';
|
|
18
|
+
const clientDir = process.env.GMTR_DIR || join(home, 'gmtr-client');
|
|
19
|
+
runLegacyMigration({
|
|
20
|
+
homeDir: home,
|
|
21
|
+
clientDir,
|
|
22
|
+
includeOptionalUx,
|
|
23
|
+
apply,
|
|
24
|
+
log,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
main();
|
package/bin/get-token.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gmtr-login — Authenticate with the gramatr server
|
|
4
|
+
*
|
|
5
|
+
* Opens the grāmatr dashboard login flow, captures a Firebase ID token
|
|
6
|
+
* on localhost, and stores it in ~/.gmtr.json.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun gmtr-login.ts # Interactive browser login via Firebase dashboard
|
|
10
|
+
* bun gmtr-login.ts --token <token> # Paste a token directly (API key or Firebase token)
|
|
11
|
+
* bun gmtr-login.ts --status # Check current auth status
|
|
12
|
+
* bun gmtr-login.ts --logout # Remove stored credentials
|
|
13
|
+
*
|
|
14
|
+
* Token is stored in ~/.gmtr.json under the "token" key.
|
|
15
|
+
* The GMTRPromptEnricher hook reads this on every prompt.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomBytes } from 'crypto';
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
20
|
+
import { join } from 'path';
|
|
21
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http';
|
|
22
|
+
|
|
23
|
+
// ── Config ──
|
|
24
|
+
|
|
25
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
|
26
|
+
const CONFIG_PATH = join(HOME, '.gmtr.json');
|
|
27
|
+
const DEFAULT_SERVER = process.env.GMTR_URL || 'https://api.gramatr.com/mcp';
|
|
28
|
+
// Strip /mcp suffix to get base URL
|
|
29
|
+
const SERVER_BASE = DEFAULT_SERVER.replace(/\/mcp\/?$/, '');
|
|
30
|
+
const DASHBOARD_BASE = process.env.GMTR_DASHBOARD_URL || (() => {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(SERVER_BASE);
|
|
33
|
+
if (url.hostname.startsWith('api.')) {
|
|
34
|
+
url.hostname = `app.${url.hostname.slice(4)}`;
|
|
35
|
+
}
|
|
36
|
+
url.pathname = '';
|
|
37
|
+
url.search = '';
|
|
38
|
+
url.hash = '';
|
|
39
|
+
return url.toString().replace(/\/$/, '');
|
|
40
|
+
} catch {
|
|
41
|
+
return 'https://app.gramatr.com';
|
|
42
|
+
}
|
|
43
|
+
})();
|
|
44
|
+
const CALLBACK_PORT = 58787; // Must match server's redirect_uris
|
|
45
|
+
|
|
46
|
+
// ── HTML Templates ──
|
|
47
|
+
|
|
48
|
+
const BRAND_CSS = `
|
|
49
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
50
|
+
body {
|
|
51
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
52
|
+
background: #0a0e17;
|
|
53
|
+
color: #e0e6ed;
|
|
54
|
+
min-height: 100vh;
|
|
55
|
+
display: flex;
|
|
56
|
+
align-items: center;
|
|
57
|
+
justify-content: center;
|
|
58
|
+
}
|
|
59
|
+
.card {
|
|
60
|
+
background: #141b2d;
|
|
61
|
+
border: 1px solid #1e2940;
|
|
62
|
+
border-radius: 16px;
|
|
63
|
+
padding: 48px;
|
|
64
|
+
max-width: 440px;
|
|
65
|
+
width: 90%;
|
|
66
|
+
text-align: center;
|
|
67
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
|
68
|
+
}
|
|
69
|
+
.logo {
|
|
70
|
+
font-size: 28px;
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
letter-spacing: -0.5px;
|
|
73
|
+
margin-bottom: 8px;
|
|
74
|
+
}
|
|
75
|
+
.logo .accent { color: #00b4d8; }
|
|
76
|
+
.logo .dim { color: #5a6a8a; }
|
|
77
|
+
.subtitle {
|
|
78
|
+
color: #5a6a8a;
|
|
79
|
+
font-size: 13px;
|
|
80
|
+
margin-bottom: 32px;
|
|
81
|
+
}
|
|
82
|
+
.status {
|
|
83
|
+
font-size: 48px;
|
|
84
|
+
margin-bottom: 16px;
|
|
85
|
+
}
|
|
86
|
+
h2 {
|
|
87
|
+
font-size: 20px;
|
|
88
|
+
font-weight: 600;
|
|
89
|
+
margin-bottom: 12px;
|
|
90
|
+
}
|
|
91
|
+
h2.success { color: #00b4d8; }
|
|
92
|
+
h2.error { color: #e74c3c; }
|
|
93
|
+
p {
|
|
94
|
+
color: #7a8aaa;
|
|
95
|
+
font-size: 14px;
|
|
96
|
+
line-height: 1.6;
|
|
97
|
+
}
|
|
98
|
+
.hint {
|
|
99
|
+
margin-top: 24px;
|
|
100
|
+
padding-top: 24px;
|
|
101
|
+
border-top: 1px solid #1e2940;
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
color: #4a5a7a;
|
|
104
|
+
}
|
|
105
|
+
`;
|
|
106
|
+
|
|
107
|
+
function htmlPage(title: string, body: string): string {
|
|
108
|
+
return `<!DOCTYPE html>
|
|
109
|
+
<html lang="en"><head>
|
|
110
|
+
<meta charset="utf-8">
|
|
111
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
112
|
+
<title>${title} — gramatr</title>
|
|
113
|
+
<style>${BRAND_CSS}</style>
|
|
114
|
+
</head><body>
|
|
115
|
+
<div class="card">
|
|
116
|
+
<div class="logo"><span class="accent">gr</span>āma<span class="accent">tr</span></div>
|
|
117
|
+
<div class="subtitle">your cross-agent AI brain</div>
|
|
118
|
+
${body}
|
|
119
|
+
</div>
|
|
120
|
+
</body></html>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function successPage(): string {
|
|
124
|
+
return htmlPage('Authenticated', `
|
|
125
|
+
<div class="status">✓</div>
|
|
126
|
+
<h2 class="success">Authenticated</h2>
|
|
127
|
+
<p>Token saved. You can close this tab and return to your terminal.</p>
|
|
128
|
+
<div class="hint">gramatr intelligence is now active across all your AI tools.</div>
|
|
129
|
+
`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function errorPage(title: string, detail: string): string {
|
|
133
|
+
return htmlPage('Error', `
|
|
134
|
+
<div class="status">✗</div>
|
|
135
|
+
<h2 class="error">${title}</h2>
|
|
136
|
+
<p>${detail}</p>
|
|
137
|
+
<div class="hint">Return to your terminal and try again, or use <code>gmtr-login --token</code> to paste a token directly.</div>
|
|
138
|
+
`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Headless Detection ──
|
|
142
|
+
|
|
143
|
+
function isHeadless(): boolean {
|
|
144
|
+
// SSH session without display forwarding
|
|
145
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
|
|
146
|
+
if (!process.env.DISPLAY && process.platform !== 'darwin') return true;
|
|
147
|
+
}
|
|
148
|
+
// Docker / CI / no TTY
|
|
149
|
+
if (process.env.CI || process.env.DOCKER) return true;
|
|
150
|
+
// Linux without display
|
|
151
|
+
if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Helpers ──
|
|
156
|
+
|
|
157
|
+
function readConfig(): Record<string, any> {
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
160
|
+
} catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function writeConfig(config: Record<string, any>): void {
|
|
166
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function checkServerHealth(): Promise<{ ok: boolean; version?: string; error?: string }> {
|
|
170
|
+
try {
|
|
171
|
+
const res = await fetch(`${SERVER_BASE}/health`, { signal: AbortSignal.timeout(5000) });
|
|
172
|
+
if (res.ok) {
|
|
173
|
+
const data = await res.json() as any;
|
|
174
|
+
return { ok: true, version: data.version };
|
|
175
|
+
}
|
|
176
|
+
return { ok: false, error: `HTTP ${res.status}` };
|
|
177
|
+
} catch (e: any) {
|
|
178
|
+
return { ok: false, error: e.message };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function testToken(token: string): Promise<{ valid: boolean; user?: string; error?: string }> {
|
|
183
|
+
try {
|
|
184
|
+
const res = await fetch(`${SERVER_BASE}/mcp`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
Accept: 'application/json, text/event-stream',
|
|
189
|
+
Authorization: `Bearer ${token}`,
|
|
190
|
+
},
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
jsonrpc: '2.0',
|
|
193
|
+
id: 1,
|
|
194
|
+
method: 'tools/call',
|
|
195
|
+
params: { name: 'aggregate_stats', arguments: {} },
|
|
196
|
+
}),
|
|
197
|
+
signal: AbortSignal.timeout(10000),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const text = await res.text();
|
|
201
|
+
|
|
202
|
+
// Check for auth errors
|
|
203
|
+
if (text.includes('JWT token is required') || text.includes('signature validation failed') || text.includes('Unauthorized')) {
|
|
204
|
+
return { valid: false, error: 'Token rejected by server' };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Check for successful response
|
|
208
|
+
for (const line of text.split('\n')) {
|
|
209
|
+
if (line.startsWith('data: ')) {
|
|
210
|
+
try {
|
|
211
|
+
const d = JSON.parse(line.slice(6));
|
|
212
|
+
if (d?.result?.isError) {
|
|
213
|
+
return { valid: false, error: d.result.content?.[0]?.text || 'Unknown error' };
|
|
214
|
+
}
|
|
215
|
+
if (d?.result?.content?.[0]?.text) {
|
|
216
|
+
return { valid: true, user: 'authenticated' };
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return { valid: false, error: 'Unexpected response' };
|
|
225
|
+
} catch (e: any) {
|
|
226
|
+
return { valid: false, error: e.message };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function startDeviceAuthorization(): Promise<{
|
|
231
|
+
device_code: string;
|
|
232
|
+
user_code: string;
|
|
233
|
+
verification_uri: string;
|
|
234
|
+
verification_uri_complete?: string;
|
|
235
|
+
expires_in: number;
|
|
236
|
+
interval: number;
|
|
237
|
+
}> {
|
|
238
|
+
const res = await fetch(`${SERVER_BASE}/device/start`, {
|
|
239
|
+
method: 'POST',
|
|
240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
body: JSON.stringify({ client_name: 'gmtr-login' }),
|
|
242
|
+
signal: AbortSignal.timeout(10000),
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const payload = await res.json().catch(() => ({}));
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
|
|
248
|
+
}
|
|
249
|
+
return payload;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function pollDeviceAuthorization(deviceCode: string): Promise<string> {
|
|
253
|
+
while (true) {
|
|
254
|
+
const res = await fetch(`${SERVER_BASE}/device/token`, {
|
|
255
|
+
method: 'POST',
|
|
256
|
+
headers: { 'Content-Type': 'application/json' },
|
|
257
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
258
|
+
signal: AbortSignal.timeout(10000),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const payload = await res.json().catch(() => ({}));
|
|
262
|
+
if (res.ok && payload.access_token) {
|
|
263
|
+
return payload.access_token as string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if ((res.status === 428 || res.status === 400) && payload.error === 'authorization_pending') {
|
|
267
|
+
const waitSeconds = Math.max(1, Number(payload.interval) || 5);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
throw new Error(payload.error_description || payload.error || `HTTP ${res.status}`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Commands ──
|
|
277
|
+
|
|
278
|
+
async function showStatus(): Promise<void> {
|
|
279
|
+
console.log('\n gramatr authentication status\n');
|
|
280
|
+
|
|
281
|
+
const config = readConfig();
|
|
282
|
+
const token = config.token;
|
|
283
|
+
|
|
284
|
+
console.log(` Server: ${SERVER_BASE}`);
|
|
285
|
+
|
|
286
|
+
const health = await checkServerHealth();
|
|
287
|
+
if (health.ok) {
|
|
288
|
+
console.log(` Health: ✓ healthy (v${health.version || 'unknown'})`);
|
|
289
|
+
} else {
|
|
290
|
+
console.log(` Health: ✗ ${health.error}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!token) {
|
|
294
|
+
console.log(' Token: ✗ not configured');
|
|
295
|
+
console.log('\n Run: bun gmtr-login.ts to authenticate\n');
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const prefix = token.substring(0, 15);
|
|
300
|
+
console.log(` Token: ${prefix}...`);
|
|
301
|
+
|
|
302
|
+
const result = await testToken(token);
|
|
303
|
+
if (result.valid) {
|
|
304
|
+
console.log(' Auth: ✓ token is valid');
|
|
305
|
+
} else {
|
|
306
|
+
console.log(` Auth: ✗ ${result.error}`);
|
|
307
|
+
console.log('\n Run: bun gmtr-login.ts to re-authenticate\n');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
console.log('');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function logout(): Promise<void> {
|
|
314
|
+
const config = readConfig();
|
|
315
|
+
delete config.token;
|
|
316
|
+
delete config.token_type;
|
|
317
|
+
delete config.token_expires;
|
|
318
|
+
delete config.authenticated_at;
|
|
319
|
+
writeConfig(config);
|
|
320
|
+
console.log('\n ✓ Logged out. Token removed from ~/.gmtr.json\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function loginWithToken(token: string): Promise<void> {
|
|
324
|
+
console.log('\n Testing token...');
|
|
325
|
+
|
|
326
|
+
const result = await testToken(token);
|
|
327
|
+
if (result.valid) {
|
|
328
|
+
const config = readConfig();
|
|
329
|
+
config.token = token;
|
|
330
|
+
config.token_type = token.startsWith('aios_sk_') || token.startsWith('gmtr_sk_') ? 'api_key' : 'oauth';
|
|
331
|
+
config.authenticated_at = new Date().toISOString();
|
|
332
|
+
writeConfig(config);
|
|
333
|
+
console.log(' ✓ Token valid. Saved to ~/.gmtr.json');
|
|
334
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
335
|
+
} else {
|
|
336
|
+
console.log(` ✗ Token rejected: ${result.error}`);
|
|
337
|
+
console.log(' Token was NOT saved.\n');
|
|
338
|
+
process.exit(1);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function loginBrowser(): Promise<void> {
|
|
343
|
+
console.log('\n gramatr login\n');
|
|
344
|
+
console.log(` Server: ${SERVER_BASE}`);
|
|
345
|
+
console.log(` Dashboard: ${DASHBOARD_BASE}`);
|
|
346
|
+
|
|
347
|
+
// Check server health first
|
|
348
|
+
const health = await checkServerHealth();
|
|
349
|
+
if (!health.ok) {
|
|
350
|
+
console.log(` ✗ Server unreachable: ${health.error}`);
|
|
351
|
+
console.log(' Cannot authenticate. Is the server running?\n');
|
|
352
|
+
process.exit(1);
|
|
353
|
+
}
|
|
354
|
+
console.log(` Health: ✓ v${health.version || 'unknown'}`);
|
|
355
|
+
|
|
356
|
+
console.log('');
|
|
357
|
+
|
|
358
|
+
// Headless environments use device auth (no local server needed)
|
|
359
|
+
if (isHeadless()) {
|
|
360
|
+
console.log(' Headless environment detected. Starting device login...\n');
|
|
361
|
+
try {
|
|
362
|
+
const device = await startDeviceAuthorization();
|
|
363
|
+
console.log(` Code: ${device.user_code}`);
|
|
364
|
+
console.log(` Open: ${device.verification_uri_complete || device.verification_uri}`);
|
|
365
|
+
console.log(' Sign in with Google or GitHub, approve the device, then return here.\n');
|
|
366
|
+
console.log(' Waiting for authorization...');
|
|
367
|
+
|
|
368
|
+
const accessToken = await Promise.race([
|
|
369
|
+
pollDeviceAuthorization(device.device_code),
|
|
370
|
+
new Promise<string>((_, reject) => setTimeout(() => reject(new Error('Device login timed out')), device.expires_in * 1000)),
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const config = readConfig();
|
|
374
|
+
config.token = accessToken;
|
|
375
|
+
config.token_type = 'oauth';
|
|
376
|
+
config.authenticated_at = new Date().toISOString();
|
|
377
|
+
config.server_url = SERVER_BASE;
|
|
378
|
+
config.dashboard_url = DASHBOARD_BASE;
|
|
379
|
+
writeConfig(config);
|
|
380
|
+
|
|
381
|
+
console.log('');
|
|
382
|
+
console.log(' ✓ Authenticated successfully');
|
|
383
|
+
console.log(' Token saved to ~/.gmtr.json');
|
|
384
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
385
|
+
return;
|
|
386
|
+
} catch (e: any) {
|
|
387
|
+
console.log(` ✗ Device login failed: ${e.message}`);
|
|
388
|
+
console.log(' Fallback: gmtr-login --token\n');
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Browser environments use local callback server
|
|
394
|
+
const state = randomBytes(16).toString('base64url');
|
|
395
|
+
const callbackUrl = `http://localhost:${CALLBACK_PORT}/callback`;
|
|
396
|
+
|
|
397
|
+
const tokenPromise = new Promise<string>((resolve, reject) => {
|
|
398
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
399
|
+
const url = new URL(req.url || '/', `http://localhost:${CALLBACK_PORT}`);
|
|
400
|
+
|
|
401
|
+
if (url.pathname !== '/callback') {
|
|
402
|
+
res.writeHead(404);
|
|
403
|
+
res.end('Not found');
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const token = url.searchParams.get('token');
|
|
408
|
+
const returnedState = url.searchParams.get('state');
|
|
409
|
+
const error = url.searchParams.get('error');
|
|
410
|
+
|
|
411
|
+
if (error) {
|
|
412
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
413
|
+
res.end(errorPage('Authentication Failed', error));
|
|
414
|
+
server.close();
|
|
415
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!token || returnedState !== state) {
|
|
420
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
421
|
+
res.end(errorPage('Invalid Callback', 'Missing Firebase token or state mismatch. Please try again.'));
|
|
422
|
+
server.close();
|
|
423
|
+
reject(new Error('Invalid callback'));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const validation = await testToken(token);
|
|
429
|
+
if (!validation.valid) {
|
|
430
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
431
|
+
res.end(errorPage('Token Validation Failed', validation.error || 'Server rejected token'));
|
|
432
|
+
server.close();
|
|
433
|
+
reject(new Error(validation.error || 'Server rejected token'));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
438
|
+
res.end(successPage());
|
|
439
|
+
server.close();
|
|
440
|
+
resolve(token);
|
|
441
|
+
} catch (e: any) {
|
|
442
|
+
res.writeHead(500, { 'Content-Type': 'text/html' });
|
|
443
|
+
res.end(errorPage('Unexpected Error', e.message));
|
|
444
|
+
server.close();
|
|
445
|
+
reject(e);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
server.listen(CALLBACK_PORT, () => {
|
|
450
|
+
// Server ready
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// Timeout after 2 minutes
|
|
454
|
+
setTimeout(() => {
|
|
455
|
+
server.close();
|
|
456
|
+
reject(new Error('Login timed out after 2 minutes'));
|
|
457
|
+
}, 120000);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const authorizeUrl = new URL('/login', `${DASHBOARD_BASE}/`);
|
|
461
|
+
authorizeUrl.searchParams.set('callback', callbackUrl);
|
|
462
|
+
authorizeUrl.searchParams.set('state', state);
|
|
463
|
+
|
|
464
|
+
console.log(' Opening browser for authentication...');
|
|
465
|
+
console.log(` If it doesn't open, visit:`);
|
|
466
|
+
console.log(` ${authorizeUrl.toString()}`);
|
|
467
|
+
console.log('');
|
|
468
|
+
|
|
469
|
+
// Open browser
|
|
470
|
+
const { exec } = await import('child_process');
|
|
471
|
+
const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
472
|
+
exec(`${openCmd} "${authorizeUrl.toString()}"`);
|
|
473
|
+
|
|
474
|
+
console.log(' Waiting for authorization...');
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
const accessToken = await tokenPromise;
|
|
478
|
+
|
|
479
|
+
// Save token
|
|
480
|
+
const config = readConfig();
|
|
481
|
+
config.token = accessToken;
|
|
482
|
+
config.token_type = 'firebase';
|
|
483
|
+
config.authenticated_at = new Date().toISOString();
|
|
484
|
+
config.server_url = SERVER_BASE;
|
|
485
|
+
config.dashboard_url = DASHBOARD_BASE;
|
|
486
|
+
writeConfig(config);
|
|
487
|
+
|
|
488
|
+
console.log('');
|
|
489
|
+
console.log(' ✓ Authenticated successfully');
|
|
490
|
+
console.log(' Token saved to ~/.gmtr.json');
|
|
491
|
+
console.log(' gramatr intelligence is now active.\n');
|
|
492
|
+
} catch (e: any) {
|
|
493
|
+
console.log(`\n ✗ Authentication failed: ${e.message}\n`);
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── CLI ──
|
|
499
|
+
|
|
500
|
+
const args = process.argv.slice(2);
|
|
501
|
+
|
|
502
|
+
if (args.includes('--status') || args.includes('status')) {
|
|
503
|
+
await showStatus();
|
|
504
|
+
} else if (args.includes('--logout') || args.includes('logout')) {
|
|
505
|
+
await logout();
|
|
506
|
+
} else if (args.includes('--token') || args.includes('-t')) {
|
|
507
|
+
const tokenIdx = args.indexOf('--token') !== -1 ? args.indexOf('--token') : args.indexOf('-t');
|
|
508
|
+
const token = args[tokenIdx + 1];
|
|
509
|
+
if (!token) {
|
|
510
|
+
// Interactive paste mode — like Claude's login
|
|
511
|
+
console.log('\n Paste your gramatr token below.');
|
|
512
|
+
console.log(' (API keys start with aios_sk_ or gmtr_sk_)\n');
|
|
513
|
+
process.stdout.write(' Token: ');
|
|
514
|
+
|
|
515
|
+
const { createInterface } = await import('readline');
|
|
516
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
517
|
+
const pastedToken = await new Promise<string>((resolve) => {
|
|
518
|
+
rl.on('line', (line: string) => { rl.close(); resolve(line.trim()); });
|
|
519
|
+
});
|
|
520
|
+
if (!pastedToken) {
|
|
521
|
+
console.log(' No token provided.\n');
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
await loginWithToken(pastedToken);
|
|
525
|
+
} else {
|
|
526
|
+
await loginWithToken(token);
|
|
527
|
+
}
|
|
528
|
+
} else if (args.includes('--help') || args.includes('-h')) {
|
|
529
|
+
console.log(`
|
|
530
|
+
gmtr-login — Authenticate with the gramatr server
|
|
531
|
+
|
|
532
|
+
Usage:
|
|
533
|
+
gmtr-login Interactive dashboard login (browser or headless device flow)
|
|
534
|
+
gmtr-login --token Paste a token (API key or Firebase token)
|
|
535
|
+
gmtr-login --token <t> Provide token directly
|
|
536
|
+
gmtr-login --status Check authentication status
|
|
537
|
+
gmtr-login --logout Remove stored credentials
|
|
538
|
+
gmtr-login --help Show this help
|
|
539
|
+
|
|
540
|
+
Token storage: ~/.gmtr.json
|
|
541
|
+
Server: ${SERVER_BASE}
|
|
542
|
+
Dashboard: ${DASHBOARD_BASE}
|
|
543
|
+
`);
|
|
544
|
+
} else {
|
|
545
|
+
// Default: browser login flow
|
|
546
|
+
await loginBrowser();
|
|
547
|
+
}
|