@gramatr/client 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/AGENTS.md +17 -0
  2. package/CLAUDE.md +18 -0
  3. package/README.md +108 -0
  4. package/bin/add-api-key.ts +264 -0
  5. package/bin/clean-legacy-install.ts +28 -0
  6. package/bin/clear-creds.ts +141 -0
  7. package/bin/get-token.py +3 -0
  8. package/bin/gmtr-login.ts +599 -0
  9. package/bin/gramatr.js +36 -0
  10. package/bin/gramatr.ts +374 -0
  11. package/bin/install.ts +716 -0
  12. package/bin/lib/config.ts +57 -0
  13. package/bin/lib/git.ts +111 -0
  14. package/bin/lib/stdin.ts +53 -0
  15. package/bin/logout.ts +76 -0
  16. package/bin/render-claude-hooks.ts +16 -0
  17. package/bin/statusline.ts +81 -0
  18. package/bin/uninstall.ts +289 -0
  19. package/chatgpt/README.md +95 -0
  20. package/chatgpt/install.ts +140 -0
  21. package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
  22. package/codex/README.md +28 -0
  23. package/codex/hooks/session-start.ts +73 -0
  24. package/codex/hooks/stop.ts +34 -0
  25. package/codex/hooks/user-prompt-submit.ts +79 -0
  26. package/codex/install.ts +116 -0
  27. package/codex/lib/codex-hook-utils.ts +48 -0
  28. package/codex/lib/codex-install-utils.ts +123 -0
  29. package/core/auth.ts +170 -0
  30. package/core/feedback.ts +55 -0
  31. package/core/formatting.ts +179 -0
  32. package/core/install.ts +107 -0
  33. package/core/installer-cli.ts +122 -0
  34. package/core/migration.ts +479 -0
  35. package/core/routing.ts +108 -0
  36. package/core/session.ts +202 -0
  37. package/core/targets.ts +292 -0
  38. package/core/types.ts +179 -0
  39. package/core/version-check.ts +219 -0
  40. package/core/version.ts +47 -0
  41. package/desktop/README.md +72 -0
  42. package/desktop/build-mcpb.ts +166 -0
  43. package/desktop/install.ts +136 -0
  44. package/desktop/lib/desktop-install-utils.ts +70 -0
  45. package/gemini/README.md +95 -0
  46. package/gemini/hooks/session-start.ts +72 -0
  47. package/gemini/hooks/stop.ts +30 -0
  48. package/gemini/hooks/user-prompt-submit.ts +77 -0
  49. package/gemini/install.ts +281 -0
  50. package/gemini/lib/gemini-hook-utils.ts +63 -0
  51. package/gemini/lib/gemini-install-utils.ts +169 -0
  52. package/hooks/GMTRPromptEnricher.hook.ts +651 -0
  53. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  54. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  55. package/hooks/GMTRToolTracker.hook.ts +181 -0
  56. package/hooks/StopOrchestrator.hook.ts +78 -0
  57. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  58. package/hooks/lib/gmtr-hook-utils.ts +770 -0
  59. package/hooks/lib/identity.ts +227 -0
  60. package/hooks/lib/notify.ts +46 -0
  61. package/hooks/lib/paths.ts +104 -0
  62. package/hooks/lib/transcript-parser.ts +452 -0
  63. package/hooks/session-end.hook.ts +168 -0
  64. package/hooks/session-start.hook.ts +501 -0
  65. package/package.json +63 -0
@@ -0,0 +1,57 @@
1
+ /**
2
+ * gramatr client config resolver (statusline refactor, #495).
3
+ *
4
+ * Single-purpose helper that returns the server URL + auth token with
5
+ * NO reads of ~/.claude* or ~/gmtr-client/settings.json. This is the
6
+ * statusline-specific config path; the richer installer auth lives in
7
+ * core/auth.ts for a reason — this file must stay small and side-effect-free
8
+ * so it can be loaded in a 2s shim without touching disk more than once.
9
+ *
10
+ * Resolution:
11
+ * URL — GMTR_URL → ~/.gmtr.json.url → https://api.gramatr.com
12
+ * Token — GMTR_TOKEN → AIOS_MCP_TOKEN → ~/.gmtr.json.token → null
13
+ */
14
+ import { existsSync, readFileSync } from 'fs';
15
+ import { homedir } from 'os';
16
+ import { join } from 'path';
17
+
18
+ export interface GramatrConfig {
19
+ url: string;
20
+ token: string | null;
21
+ }
22
+
23
+ const DEFAULT_URL = 'https://api.gramatr.com';
24
+
25
+ function getHome(): string {
26
+ return process.env.HOME || process.env.USERPROFILE || homedir();
27
+ }
28
+
29
+ function readGmtrJson(): Record<string, any> | null {
30
+ const path = join(getHome(), '.gmtr.json');
31
+ if (!existsSync(path)) return null;
32
+ try {
33
+ return JSON.parse(readFileSync(path, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ export function getGramatrConfig(): GramatrConfig {
40
+ const json = readGmtrJson();
41
+
42
+ const rawUrl =
43
+ process.env.GMTR_URL ||
44
+ (typeof json?.url === 'string' && json.url) ||
45
+ DEFAULT_URL;
46
+ // GMTR_URL is conventionally the MCP endpoint (e.g. https://api.gramatr.com/mcp).
47
+ // REST calls need the API base — strip a trailing /mcp segment.
48
+ const url = (rawUrl as string).replace(/\/mcp\/?$/, '');
49
+
50
+ const token =
51
+ process.env.GMTR_TOKEN ||
52
+ process.env.AIOS_MCP_TOKEN ||
53
+ (typeof json?.token === 'string' && json.token) ||
54
+ null;
55
+
56
+ return { url: url as string, token };
57
+ }
package/bin/lib/git.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Git state collection for the statusline client (#495).
3
+ *
4
+ * Three subprocess calls, not seven:
5
+ * 1. git rev-parse --show-toplevel — is this even a repo?
6
+ * 2. git remote get-url origin — project_id derivation
7
+ * 3. git status --porcelain=v2 --branch — branch + file counts in one call
8
+ *
9
+ * A fourth call (git log -1 --format=%cr) runs only when the porcelain output
10
+ * indicates we have commits, keeping the empty-repo path clean.
11
+ *
12
+ * All functions are pure in terms of parseable input → output (parsePorcelain,
13
+ * parseProjectId) so they can be tested without spawning git.
14
+ */
15
+ import { execSync } from 'child_process';
16
+
17
+ export interface GitStateFields {
18
+ branch: string;
19
+ ahead: number;
20
+ behind: number;
21
+ modified: number;
22
+ untracked: number;
23
+ stash: number;
24
+ last_commit_age: string;
25
+ }
26
+
27
+ export interface GitInfo {
28
+ projectId: string;
29
+ state: GitStateFields;
30
+ }
31
+
32
+ export function parseProjectId(remoteUrl: string): string {
33
+ if (!remoteUrl) return '';
34
+ // Handle both https://github.com/org/repo(.git) and git@github.com:org/repo(.git)
35
+ const cleaned = remoteUrl.trim().replace(/\.git$/, '');
36
+ const sshMatch = cleaned.match(/[:/]([^:/]+\/[^:/]+)$/);
37
+ if (sshMatch) return sshMatch[1];
38
+ return cleaned;
39
+ }
40
+
41
+ /**
42
+ * Parse `git status --porcelain=v2 --branch` output.
43
+ *
44
+ * Header lines start with '#'. Entry lines start with '1', '2', 'u', or '?'.
45
+ * We count:
46
+ * branch from `# branch.head <name>`
47
+ * ahead/behind from `# branch.ab +N -M`
48
+ * modified from '1 ', '2 ', 'u ' entries
49
+ * untracked from '? ' entries
50
+ */
51
+ export function parsePorcelain(out: string): GitStateFields {
52
+ const state: GitStateFields = {
53
+ branch: '',
54
+ ahead: 0,
55
+ behind: 0,
56
+ modified: 0,
57
+ untracked: 0,
58
+ stash: 0,
59
+ last_commit_age: '',
60
+ };
61
+
62
+ for (const line of out.split('\n')) {
63
+ if (!line) continue;
64
+ if (line.startsWith('# branch.head ')) {
65
+ state.branch = line.slice('# branch.head '.length).trim();
66
+ } else if (line.startsWith('# branch.ab ')) {
67
+ const parts = line.slice('# branch.ab '.length).trim().split(/\s+/);
68
+ for (const p of parts) {
69
+ if (p.startsWith('+')) state.ahead = parseInt(p.slice(1), 10) || 0;
70
+ else if (p.startsWith('-')) state.behind = parseInt(p.slice(1), 10) || 0;
71
+ }
72
+ } else if (line[0] === '1' || line[0] === '2' || line[0] === 'u') {
73
+ state.modified += 1;
74
+ } else if (line[0] === '?') {
75
+ state.untracked += 1;
76
+ }
77
+ }
78
+
79
+ return state;
80
+ }
81
+
82
+ function execOrNull(cmd: string, cwd: string): string | null {
83
+ try {
84
+ return execSync(cmd, { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ export function getGitState(cwd: string): GitInfo | null {
91
+ const root = execOrNull('git rev-parse --show-toplevel', cwd);
92
+ if (!root) return null;
93
+
94
+ const remote = execOrNull('git remote get-url origin', cwd) ?? '';
95
+ const porcelain = execOrNull('git status --porcelain=v2 --branch', cwd) ?? '';
96
+ const state = parsePorcelain(porcelain);
97
+
98
+ // Stash count — cheap single call; returns empty on no stashes
99
+ const stashRaw = execOrNull('git stash list', cwd) ?? '';
100
+ state.stash = stashRaw ? stashRaw.split('\n').filter(Boolean).length : 0;
101
+
102
+ // Last commit age — only meaningful if branch has commits
103
+ if (state.branch && state.branch !== '(detached)') {
104
+ const age = execOrNull('git log -1 --format=%cr', cwd);
105
+ if (age) state.last_commit_age = age.trim();
106
+ }
107
+
108
+ const projectId = parseProjectId(remote) || 'local';
109
+
110
+ return { projectId, state };
111
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Minimal stdin reader for the statusline shim (#495).
3
+ *
4
+ * Claude Code pipes a JSON blob to statusline commands. We read it with a
5
+ * hard cap + timeout so a broken pipe never hangs the render.
6
+ */
7
+
8
+ export interface CCInput {
9
+ session_id?: string;
10
+ cwd?: string;
11
+ workspace?: { current_dir?: string };
12
+ }
13
+
14
+ const MAX_STDIN_BYTES = 64 * 1024;
15
+ const STDIN_TIMEOUT_MS = 500;
16
+
17
+ export async function readStdin(): Promise<CCInput> {
18
+ // If stdin is a TTY there's nothing to read — return empty immediately.
19
+ if (process.stdin.isTTY) return {};
20
+
21
+ return new Promise((resolve) => {
22
+ let data = '';
23
+ let settled = false;
24
+
25
+ const finish = (input: CCInput) => {
26
+ if (settled) return;
27
+ settled = true;
28
+ resolve(input);
29
+ };
30
+
31
+ const timer = setTimeout(() => finish({}), STDIN_TIMEOUT_MS);
32
+
33
+ process.stdin.setEncoding('utf8');
34
+ process.stdin.on('data', (chunk: string) => {
35
+ data += chunk;
36
+ if (data.length > MAX_STDIN_BYTES) {
37
+ data = data.slice(0, MAX_STDIN_BYTES);
38
+ }
39
+ });
40
+ process.stdin.on('end', () => {
41
+ clearTimeout(timer);
42
+ try {
43
+ finish(data.trim() ? (JSON.parse(data) as CCInput) : {});
44
+ } catch {
45
+ finish({});
46
+ }
47
+ });
48
+ process.stdin.on('error', () => {
49
+ clearTimeout(timer);
50
+ finish({});
51
+ });
52
+ });
53
+ }
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,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { buildClaudeHooksFile } from '../core/install.ts';
4
+
5
+ function main(): void {
6
+ const clientDir = process.argv[2];
7
+ if (!clientDir) {
8
+ throw new Error('Usage: render-claude-hooks.ts <client-dir>');
9
+ }
10
+
11
+ const includeOptionalUx = process.env.GMTR_ENABLE_OPTIONAL_CLAUDE_UX === '1';
12
+ const hooks = buildClaudeHooksFile(clientDir, { includeOptionalUx });
13
+ process.stdout.write(`${JSON.stringify(hooks.hooks, null, 2)}\n`);
14
+ }
15
+
16
+ main();
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr status line — thin client shim (#495).
4
+ *
5
+ * The server owns composition and rendering. This file is a pipe:
6
+ * 1. Read stdin (Claude Code JSON — session_id, cwd)
7
+ * 2. Collect git state (3 subprocess calls, see lib/git.ts)
8
+ * 3. POST /api/v1/statusline/render with render_as=native
9
+ * 4. Write the server's rendered string to stdout
10
+ *
11
+ * Anti-criteria (enforced by regression grep in CI — see
12
+ * lib/__tests__/statusline-anti-criteria.test.ts for the tombstone list):
13
+ * - No temp-file reads or writes
14
+ * - No Claude config reads
15
+ * - No legacy client settings reads
16
+ * - No display of Claude-owned data (context window, model name)
17
+ * - No local caches (statusline is a view, not a database)
18
+ * - One and only one network call
19
+ *
20
+ * Failure modes are FIRST-CLASS:
21
+ * - not a repo → silent (exit 0, nothing written)
22
+ * - no token → authentication prompt
23
+ * - offline/timeout/non-2xx → offline indicator
24
+ */
25
+
26
+ import { getGitState } from './lib/git.js';
27
+ import { getGramatrConfig } from './lib/config.js';
28
+ import { readStdin } from './lib/stdin.js';
29
+
30
+ const CLIENT_TIMEOUT_MS = 2000;
31
+
32
+ type Size = 'small' | 'medium' | 'large';
33
+
34
+ async function main(): Promise<void> {
35
+ const input = await readStdin();
36
+ const cwd = input.workspace?.current_dir || input.cwd || process.cwd();
37
+
38
+ const git = getGitState(cwd);
39
+ if (!git) {
40
+ // Not inside a git repo — render nothing, exit clean.
41
+ return;
42
+ }
43
+
44
+ const { url, token } = getGramatrConfig();
45
+ if (!token) {
46
+ process.stdout.write(' ● grāmatr │ not authenticated — run npx gramatr login\n');
47
+ return;
48
+ }
49
+
50
+ const envSize = process.env.GMTR_STATUSLINE_SIZE;
51
+ const size: Size =
52
+ envSize === 'small' || envSize === 'medium' || envSize === 'large' ? envSize : 'medium';
53
+
54
+ try {
55
+ const resp = await fetch(`${url}/api/v1/statusline/render`, {
56
+ method: 'POST',
57
+ headers: {
58
+ Authorization: `Bearer ${token}`,
59
+ 'Content-Type': 'application/json',
60
+ },
61
+ body: JSON.stringify({
62
+ project_id: git.projectId,
63
+ session_id: input.session_id || 'no-session',
64
+ size,
65
+ render_as: 'native',
66
+ git_state: git.state,
67
+ }),
68
+ signal: AbortSignal.timeout(CLIENT_TIMEOUT_MS),
69
+ });
70
+
71
+ if (!resp.ok) throw new Error(`status ${resp.status}`);
72
+ const data = (await resp.json()) as { rendered?: string };
73
+ process.stdout.write(data.rendered ?? ' ● grāmatr │ offline\n');
74
+ } catch {
75
+ process.stdout.write(' ● grāmatr │ offline\n');
76
+ }
77
+ }
78
+
79
+ main().catch(() => {
80
+ process.stdout.write(' ● grāmatr │ error\n');
81
+ });
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gramatr uninstaller — reverses install, restores configs to pre-gramatr state.
4
+ *
5
+ * Usage:
6
+ * bun uninstall.ts # Interactive — confirms each step
7
+ * bun uninstall.ts --yes # Non-interactive — uninstall everything
8
+ * bun uninstall.ts --keep-auth # Keep ~/.gmtr.json (preserve login)
9
+ * npx tsx uninstall.ts # Without bun
10
+ */
11
+
12
+ import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
13
+ import { join, dirname } from 'path';
14
+ import { createInterface } from 'readline';
15
+
16
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
17
+ const args = process.argv.slice(2);
18
+ const YES = args.includes('--yes') || args.includes('-y');
19
+ const KEEP_AUTH = args.includes('--keep-auth');
20
+
21
+ function log(msg: string): void { process.stdout.write(`${msg}\n`); }
22
+
23
+ async function confirm(msg: string): Promise<boolean> {
24
+ if (YES) return true;
25
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
26
+ return new Promise((resolve) => {
27
+ rl.question(` ${msg} [Y/n]: `, (answer) => {
28
+ rl.close();
29
+ resolve(!answer || answer.toLowerCase() === 'y');
30
+ });
31
+ });
32
+ }
33
+
34
+ function findLatestBackup(filePath: string): string | null {
35
+ const dir = dirname(filePath);
36
+ const base = filePath.split('/').pop() || '';
37
+ try {
38
+ const files = readdirSync(dir)
39
+ .filter(f => f.startsWith(`${base}.backup-`))
40
+ .sort()
41
+ .reverse();
42
+ return files.length > 0 ? join(dir, files[0]) : null;
43
+ } catch { return null; }
44
+ }
45
+
46
+ function removeGramatrFromJson(filePath: string, keys: string[]): boolean {
47
+ try {
48
+ if (!existsSync(filePath)) return false;
49
+ const data = JSON.parse(readFileSync(filePath, 'utf8'));
50
+ let changed = false;
51
+ for (const key of keys) {
52
+ if (key in data) {
53
+ delete data[key];
54
+ changed = true;
55
+ }
56
+ // Handle nested keys like "env.GMTR_TOKEN"
57
+ if (key.includes('.')) {
58
+ const [parent, child] = key.split('.');
59
+ if (data[parent] && child in data[parent]) {
60
+ delete data[parent][child];
61
+ changed = true;
62
+ }
63
+ }
64
+ }
65
+ // Remove gramatr from mcpServers
66
+ if (data.mcpServers?.gramatr) {
67
+ delete data.mcpServers.gramatr;
68
+ changed = true;
69
+ }
70
+ if (changed) {
71
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n');
72
+ }
73
+ return changed;
74
+ } catch { return false; }
75
+ }
76
+
77
+ function dirSize(dir: string): string {
78
+ try {
79
+ let total = 0;
80
+ const walk = (d: string) => {
81
+ for (const f of readdirSync(d)) {
82
+ const p = join(d, f);
83
+ const s = statSync(p);
84
+ if (s.isDirectory()) walk(p); else total += s.size;
85
+ }
86
+ };
87
+ walk(dir);
88
+ if (total > 1048576) return `${(total / 1048576).toFixed(1)}MB`;
89
+ if (total > 1024) return `${(total / 1024).toFixed(0)}KB`;
90
+ return `${total}B`;
91
+ } catch { return '?'; }
92
+ }
93
+
94
+ async function main(): Promise<void> {
95
+ log('');
96
+ log(' gramatr uninstaller');
97
+ log(' ===================');
98
+ log('');
99
+
100
+ const gmtrClient = join(HOME, 'gmtr-client');
101
+ const gmtrJson = join(HOME, '.gmtr.json');
102
+ const gmtrDotDir = join(HOME, '.gmtr');
103
+ const claudeSettings = join(HOME, '.claude', 'settings.json');
104
+ const claudeJson = join(HOME, '.claude.json');
105
+ const claudeSkills = join(HOME, '.claude', 'skills');
106
+ const claudeAgents = join(HOME, '.claude', 'agents');
107
+ const claudeCommands = join(HOME, '.claude', 'commands');
108
+ const codexHooks = join(HOME, '.codex', 'hooks.json');
109
+ const codexAgents = join(HOME, '.codex', 'AGENTS.md');
110
+ const geminiExt = join(HOME, '.gemini', 'extensions', 'gramatr');
111
+
112
+ // Detect what's installed
113
+ const installed: string[] = [];
114
+ if (existsSync(gmtrClient)) installed.push(`~/gmtr-client (${dirSize(gmtrClient)})`);
115
+ if (existsSync(gmtrDotDir)) installed.push(`~/.gmtr/ (${dirSize(gmtrDotDir)})`);
116
+ if (existsSync(gmtrJson)) installed.push('~/.gmtr.json');
117
+ if (existsSync(claudeSettings)) installed.push('~/.claude/settings.json (hooks)');
118
+ if (existsSync(claudeJson)) installed.push('~/.claude.json (MCP server)');
119
+ if (existsSync(codexHooks)) installed.push('~/.codex/hooks.json');
120
+ if (existsSync(geminiExt)) installed.push(`~/.gemini/extensions/gramatr (${dirSize(geminiExt)})`);
121
+
122
+ if (installed.length === 0) {
123
+ log(' Nothing to uninstall — gramatr is not installed.');
124
+ log('');
125
+ return;
126
+ }
127
+
128
+ log(' Found:');
129
+ for (const item of installed) log(` - ${item}`);
130
+ log('');
131
+
132
+ if (!await confirm('Proceed with uninstall?')) {
133
+ log(' Cancelled.');
134
+ return;
135
+ }
136
+
137
+ log('');
138
+
139
+ // 1. Remove ~/gmtr-client (hooks, bin, skills, agents, tools)
140
+ if (existsSync(gmtrClient)) {
141
+ rmSync(gmtrClient, { recursive: true, force: true });
142
+ log(' OK Removed ~/gmtr-client');
143
+ }
144
+
145
+ // 2. Remove ~/.gmtr/ (bun binary, PATH additions)
146
+ if (existsSync(gmtrDotDir)) {
147
+ rmSync(gmtrDotDir, { recursive: true, force: true });
148
+ log(' OK Removed ~/.gmtr/');
149
+ }
150
+
151
+ // 3. Remove ~/.gmtr.json (auth token) — unless --keep-auth
152
+ if (existsSync(gmtrJson)) {
153
+ if (KEEP_AUTH) {
154
+ log(' -- Kept ~/.gmtr.json (--keep-auth)');
155
+ } else {
156
+ rmSync(gmtrJson);
157
+ log(' OK Removed ~/.gmtr.json');
158
+ }
159
+ }
160
+
161
+ // 4. Restore ~/.claude/settings.json from backup or clean gramatr hooks
162
+ if (existsSync(claudeSettings)) {
163
+ const backup = findLatestBackup(claudeSettings);
164
+ if (backup && await confirm(`Restore settings.json from backup (${backup.split('/').pop()})?`)) {
165
+ const backupContent = readFileSync(backup, 'utf8');
166
+ writeFileSync(claudeSettings, backupContent);
167
+ log(` OK Restored ~/.claude/settings.json from ${backup.split('/').pop()}`);
168
+ } else {
169
+ // Remove gramatr hooks from settings
170
+ try {
171
+ const settings = JSON.parse(readFileSync(claudeSettings, 'utf8'));
172
+ if (settings.hooks) {
173
+ // Remove hook entries that reference gmtr-client
174
+ for (const [event, hooks] of Object.entries(settings.hooks)) {
175
+ if (Array.isArray(hooks)) {
176
+ settings.hooks[event] = (hooks as any[]).filter(
177
+ (h: any) => !h.command?.includes('gmtr-client') && !h.command?.includes('gramatr')
178
+ );
179
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
180
+ }
181
+ }
182
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
183
+ }
184
+ if (settings.statusLine?.command?.includes('gmtr-client')) {
185
+ delete settings.statusLine;
186
+ }
187
+ writeFileSync(claudeSettings, JSON.stringify(settings, null, 2) + '\n');
188
+ log(' OK Removed gramatr hooks from ~/.claude/settings.json');
189
+ } catch {
190
+ log(' X Could not clean settings.json — restore from backup manually');
191
+ }
192
+ }
193
+ }
194
+
195
+ // 5. Remove gramatr from ~/.claude.json (MCP server + env vars)
196
+ if (existsSync(claudeJson)) {
197
+ const cleaned = removeGramatrFromJson(claudeJson, ['env.GMTR_TOKEN', 'env.AIOS_MCP_TOKEN']);
198
+ if (cleaned) log(' OK Removed gramatr MCP server + env vars from ~/.claude.json');
199
+ }
200
+
201
+ // 6. Remove gramatr skills from ~/.claude/skills/
202
+ if (existsSync(claudeSkills)) {
203
+ try {
204
+ const skillDirs = readdirSync(claudeSkills);
205
+ let removed = 0;
206
+ for (const dir of skillDirs) {
207
+ const skillPath = join(claudeSkills, dir);
208
+ // Check if it's a gramatr-installed skill (has gramatr marker or matches known patterns)
209
+ if (existsSync(join(skillPath, '.gramatr')) || existsSync(join(skillPath, 'README.md'))) {
210
+ rmSync(skillPath, { recursive: true, force: true });
211
+ removed++;
212
+ }
213
+ }
214
+ if (removed > 0) log(` OK Removed ${removed} skill directories from ~/.claude/skills/`);
215
+ } catch { /* non-critical */ }
216
+ }
217
+
218
+ // 7. Remove gramatr agents from ~/.claude/agents/
219
+ if (existsSync(claudeAgents)) {
220
+ try {
221
+ rmSync(claudeAgents, { recursive: true, force: true });
222
+ log(' OK Removed ~/.claude/agents/');
223
+ } catch { /* non-critical */ }
224
+ }
225
+
226
+ // 8. Remove gramatr commands from ~/.claude/commands/
227
+ if (existsSync(claudeCommands)) {
228
+ try {
229
+ const cmds = readdirSync(claudeCommands).filter(f => f.startsWith('gmtr-') || f.startsWith('gramatr-'));
230
+ for (const cmd of cmds) {
231
+ rmSync(join(claudeCommands, cmd), { recursive: true, force: true });
232
+ }
233
+ if (cmds.length > 0) log(` OK Removed ${cmds.length} gramatr commands from ~/.claude/commands/`);
234
+ } catch { /* non-critical */ }
235
+ }
236
+
237
+ // 9. Clean Codex config
238
+ if (existsSync(codexHooks)) {
239
+ try {
240
+ const hooks = JSON.parse(readFileSync(codexHooks, 'utf8'));
241
+ if (hooks.hooks) {
242
+ for (const [event, eventHooks] of Object.entries(hooks.hooks)) {
243
+ if (Array.isArray(eventHooks)) {
244
+ hooks.hooks[event] = (eventHooks as any[]).filter(
245
+ (h: any) => !h.command?.includes('gmtr-client') && !h.command?.includes('gramatr')
246
+ );
247
+ if (hooks.hooks[event].length === 0) delete hooks.hooks[event];
248
+ }
249
+ }
250
+ writeFileSync(codexHooks, JSON.stringify(hooks, null, 2) + '\n');
251
+ log(' OK Cleaned gramatr hooks from ~/.codex/hooks.json');
252
+ }
253
+ } catch { /* non-critical */ }
254
+ }
255
+ if (existsSync(codexAgents)) {
256
+ // Remove managed block from AGENTS.md
257
+ try {
258
+ let content = readFileSync(codexAgents, 'utf8');
259
+ const startMarker = '<!-- GMTR-CODEX-START -->';
260
+ const endMarker = '<!-- GMTR-CODEX-END -->';
261
+ const startIdx = content.indexOf(startMarker);
262
+ const endIdx = content.indexOf(endMarker);
263
+ if (startIdx !== -1 && endIdx !== -1) {
264
+ content = content.slice(0, startIdx) + content.slice(endIdx + endMarker.length);
265
+ writeFileSync(codexAgents, content.trim() + '\n');
266
+ log(' OK Removed gramatr block from ~/.codex/AGENTS.md');
267
+ }
268
+ } catch { /* non-critical */ }
269
+ }
270
+
271
+ // 10. Remove Gemini extension
272
+ if (existsSync(geminiExt)) {
273
+ rmSync(geminiExt, { recursive: true, force: true });
274
+ log(' OK Removed ~/.gemini/extensions/gramatr');
275
+ }
276
+
277
+ log('');
278
+ log(' Uninstall complete.');
279
+ log(' Restart Claude Code / Codex / Gemini CLI to apply changes.');
280
+ if (KEEP_AUTH) {
281
+ log(' Auth token preserved in ~/.gmtr.json (use --token with next install).');
282
+ }
283
+ log('');
284
+ }
285
+
286
+ main().catch((err) => {
287
+ log(` Error: ${err.message}`);
288
+ process.exit(1);
289
+ });