@gramatr/client 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -0
- package/CLAUDE.md +18 -0
- package/README.md +108 -0
- package/bin/add-api-key.ts +264 -0
- package/bin/clean-legacy-install.ts +28 -0
- package/bin/clear-creds.ts +141 -0
- package/bin/get-token.py +3 -0
- package/bin/gmtr-login.ts +599 -0
- package/bin/gramatr.js +36 -0
- package/bin/gramatr.ts +374 -0
- package/bin/install.ts +716 -0
- package/bin/lib/config.ts +57 -0
- package/bin/lib/git.ts +111 -0
- package/bin/lib/stdin.ts +53 -0
- package/bin/logout.ts +76 -0
- package/bin/render-claude-hooks.ts +16 -0
- package/bin/statusline.ts +81 -0
- package/bin/uninstall.ts +289 -0
- package/chatgpt/README.md +95 -0
- package/chatgpt/install.ts +140 -0
- package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
- package/codex/README.md +28 -0
- package/codex/hooks/session-start.ts +73 -0
- package/codex/hooks/stop.ts +34 -0
- package/codex/hooks/user-prompt-submit.ts +79 -0
- package/codex/install.ts +116 -0
- package/codex/lib/codex-hook-utils.ts +48 -0
- package/codex/lib/codex-install-utils.ts +123 -0
- package/core/auth.ts +170 -0
- package/core/feedback.ts +55 -0
- package/core/formatting.ts +179 -0
- package/core/install.ts +107 -0
- package/core/installer-cli.ts +122 -0
- package/core/migration.ts +479 -0
- package/core/routing.ts +108 -0
- package/core/session.ts +202 -0
- package/core/targets.ts +292 -0
- package/core/types.ts +179 -0
- package/core/version-check.ts +219 -0
- package/core/version.ts +47 -0
- package/desktop/README.md +72 -0
- package/desktop/build-mcpb.ts +166 -0
- package/desktop/install.ts +136 -0
- package/desktop/lib/desktop-install-utils.ts +70 -0
- package/gemini/README.md +95 -0
- package/gemini/hooks/session-start.ts +72 -0
- package/gemini/hooks/stop.ts +30 -0
- package/gemini/hooks/user-prompt-submit.ts +77 -0
- package/gemini/install.ts +281 -0
- package/gemini/lib/gemini-hook-utils.ts +63 -0
- package/gemini/lib/gemini-install-utils.ts +169 -0
- package/hooks/GMTRPromptEnricher.hook.ts +651 -0
- package/hooks/GMTRRatingCapture.hook.ts +198 -0
- package/hooks/GMTRSecurityValidator.hook.ts +399 -0
- package/hooks/GMTRToolTracker.hook.ts +181 -0
- package/hooks/StopOrchestrator.hook.ts +78 -0
- package/hooks/gmtr-tool-tracker-utils.ts +105 -0
- package/hooks/lib/gmtr-hook-utils.ts +770 -0
- package/hooks/lib/identity.ts +227 -0
- package/hooks/lib/notify.ts +46 -0
- package/hooks/lib/paths.ts +104 -0
- package/hooks/lib/transcript-parser.ts +452 -0
- package/hooks/session-end.hook.ts +168 -0
- package/hooks/session-start.hook.ts +501 -0
- package/package.json +63 -0
|
@@ -0,0 +1,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
|
+
}
|
package/bin/lib/stdin.ts
ADDED
|
@@ -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
|
+
});
|
package/bin/uninstall.ts
ADDED
|
@@ -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
|
+
});
|