@thegitai/cli 1.0.0-preview.4 → 1.0.0-preview.41
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/README.md +49 -4
- package/dist/bin/ai.js +119 -111
- package/dist/bin/browser-host.js +265 -0
- package/dist/src/agent-mode.js +11 -6
- package/dist/src/api/auth.js +6 -4
- package/dist/src/api/browser-login.js +152 -37
- package/dist/src/api/chat.js +286 -44
- package/dist/src/api/contracts.js +55 -1
- package/dist/src/api/default-host.js +1 -0
- package/dist/src/api/http.js +127 -7
- package/dist/src/api/models.js +29 -10
- package/dist/src/background-jobs.js +4 -2
- package/dist/src/browser/bridge.js +232 -0
- package/dist/src/browser/framing.js +42 -0
- package/dist/src/browser/native-host.js +209 -0
- package/dist/src/browser/protocol.js +46 -0
- package/dist/src/browser/session-bridge.js +104 -0
- package/dist/src/cli-args.js +19 -5
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/clipboard.js +7 -13
- package/dist/src/core/image-limits.js +56 -0
- package/dist/src/core/image-path-extractor.js +70 -3
- package/dist/src/core/session-image-store.js +246 -0
- package/dist/src/edit-journal.js +33 -2
- package/dist/src/executor.js +25 -3
- package/dist/src/help-text.js +58 -17
- package/dist/src/patcher.js +43 -4
- package/dist/src/permissions.js +288 -0
- package/dist/src/session-safety.js +0 -12
- package/dist/src/session-store.js +121 -20
- package/dist/src/session.js +19 -4
- package/dist/src/signin.js +58 -0
- package/dist/src/tool-executor.js +12 -46
- package/dist/src/tools/browser.js +628 -0
- package/dist/src/tools/delete-file.js +20 -4
- package/dist/src/tools/index.js +32 -10
- package/dist/src/tools/patch-file.js +12 -26
- package/dist/src/tools/read-image-file.js +85 -0
- package/dist/src/tools/replace-document-text.js +28 -18
- package/dist/src/tools/restore-checkpoint.js +0 -1
- package/dist/src/tools/run-command.js +14 -71
- package/dist/src/tools/run-node-script.js +12 -81
- package/dist/src/tools/save-generated-image.js +120 -0
- package/dist/src/tools/str-replace.js +12 -26
- package/dist/src/tools/undo-edit.js +1 -6
- package/dist/src/tools/write-file.js +67 -11
- package/dist/src/ui/prompt-history-store.js +1 -1
- package/dist/src/ui/repl.js +802 -195
- package/dist/src/ui/tui/bridge.js +10 -0
- package/dist/src/ui/tui/build-frame.js +517 -165
- package/dist/src/ui/tui/composer-layout.js +76 -0
- package/dist/src/ui/tui/markdown-render.js +81 -73
- package/dist/src/ui/tui/shell-input.js +227 -67
- package/dist/src/ui/tui/terminal-theme.js +28 -0
- package/dist/src/ui/tui/terminal-title.js +3 -0
- package/dist/src/ui/tui/terminal-writes.js +48 -0
- package/dist/src/ui/tui/text.js +158 -4
- package/dist/src/ui/tui/user-input.js +568 -0
- package/dist/src/utils.js +9 -0
- package/package.json +20 -8
- package/dist/src/markdown-renderer.js +0 -112
- package/dist/src/project-index.js +0 -221
- package/dist/src/tools/code-intel.js +0 -472
- package/dist/src/tools/find-symbol.js +0 -70
- package/dist/src/tools/hover-symbol.js +0 -95
- package/dist/src/tools/list-symbols.js +0 -55
- package/dist/src/tools/search-code.js +0 -37
- package/dist/src/tools/signature-help.js +0 -118
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { EXTENSION_IDS, NATIVE_HOST_NAME } from './protocol.js';
|
|
7
|
+
function browserConfigRoots(env, platform) {
|
|
8
|
+
const home = env.HOME ?? os.homedir();
|
|
9
|
+
if (platform === 'win32') {
|
|
10
|
+
const local = env.LOCALAPPDATA ?? path.join(home, 'AppData', 'Local');
|
|
11
|
+
return [
|
|
12
|
+
path.join(local, 'Google', 'Chrome', 'User Data'),
|
|
13
|
+
path.join(local, 'Google', 'Chrome Beta', 'User Data'),
|
|
14
|
+
path.join(local, 'Chromium', 'User Data'),
|
|
15
|
+
path.join(local, 'Microsoft', 'Edge', 'User Data'),
|
|
16
|
+
path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data'),
|
|
17
|
+
path.join(local, 'Vivaldi', 'User Data'),
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
if (platform === 'darwin') {
|
|
21
|
+
const support = path.join(home, 'Library', 'Application Support');
|
|
22
|
+
return [
|
|
23
|
+
path.join(support, 'Google', 'Chrome'),
|
|
24
|
+
path.join(support, 'Google', 'Chrome Beta'),
|
|
25
|
+
path.join(support, 'Chromium'),
|
|
26
|
+
path.join(support, 'Microsoft Edge'),
|
|
27
|
+
path.join(support, 'BraveSoftware', 'Brave-Browser'),
|
|
28
|
+
path.join(support, 'Vivaldi'),
|
|
29
|
+
path.join(support, 'Arc', 'User Data'),
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
const configHome = env.XDG_CONFIG_HOME ?? path.join(home, '.config');
|
|
33
|
+
return [
|
|
34
|
+
path.join(configHome, 'google-chrome'),
|
|
35
|
+
path.join(configHome, 'google-chrome-beta'),
|
|
36
|
+
path.join(configHome, 'chromium'),
|
|
37
|
+
path.join(configHome, 'microsoft-edge'),
|
|
38
|
+
path.join(configHome, 'BraveSoftware', 'Brave-Browser'),
|
|
39
|
+
path.join(configHome, 'vivaldi'),
|
|
40
|
+
];
|
|
41
|
+
}
|
|
42
|
+
function thegitaiHome(env, platform = process.platform) {
|
|
43
|
+
if (platform === 'win32') {
|
|
44
|
+
const local = env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
45
|
+
return env.THEGITAI_HOME ?? path.join(local, 'thegitai');
|
|
46
|
+
}
|
|
47
|
+
return env.THEGITAI_HOME ?? path.join(env.HOME ?? os.homedir(), '.thegitai');
|
|
48
|
+
}
|
|
49
|
+
function writeLauncher(env, hostScript, platform = process.platform) {
|
|
50
|
+
const dir = thegitaiHome(env, platform);
|
|
51
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
|
+
if (platform === 'win32') {
|
|
53
|
+
const launcherPath = path.join(dir, 'browser-bridge-host.bat');
|
|
54
|
+
const contents = [
|
|
55
|
+
'@echo off',
|
|
56
|
+
`"${process.execPath}" "${hostScript}" %*`,
|
|
57
|
+
'',
|
|
58
|
+
].join('\r\n');
|
|
59
|
+
const existingBat = fs.existsSync(launcherPath)
|
|
60
|
+
? fs.readFileSync(launcherPath, 'utf8')
|
|
61
|
+
: null;
|
|
62
|
+
if (existingBat === contents)
|
|
63
|
+
return { launcherPath, changed: false };
|
|
64
|
+
fs.writeFileSync(launcherPath, contents);
|
|
65
|
+
return { launcherPath, changed: true };
|
|
66
|
+
}
|
|
67
|
+
const launcherPath = path.join(dir, 'browser-bridge-host.sh');
|
|
68
|
+
const quote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
|
|
69
|
+
const contents = `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(hostScript)} "$@"\n`;
|
|
70
|
+
const existing = fs.existsSync(launcherPath) ? fs.readFileSync(launcherPath, 'utf8') : null;
|
|
71
|
+
if (existing === contents) {
|
|
72
|
+
try {
|
|
73
|
+
fs.chmodSync(launcherPath, 0o700);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
}
|
|
77
|
+
return { launcherPath, changed: false };
|
|
78
|
+
}
|
|
79
|
+
fs.writeFileSync(launcherPath, contents, { mode: 0o700 });
|
|
80
|
+
fs.chmodSync(launcherPath, 0o700);
|
|
81
|
+
return { launcherPath, changed: true };
|
|
82
|
+
}
|
|
83
|
+
function resolveHostScript() {
|
|
84
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
85
|
+
return path.resolve(here, '..', '..', 'bin', 'browser-host.js');
|
|
86
|
+
}
|
|
87
|
+
export function registerNativeHost({ env = process.env, platform = process.platform, hostScript = resolveHostScript(), } = {}) {
|
|
88
|
+
const errors = [];
|
|
89
|
+
const manifests = [];
|
|
90
|
+
let changed = false;
|
|
91
|
+
const { launcherPath, changed: launcherChanged } = writeLauncher(env, hostScript, platform);
|
|
92
|
+
changed = launcherChanged;
|
|
93
|
+
const manifest = {
|
|
94
|
+
name: NATIVE_HOST_NAME,
|
|
95
|
+
description: 'TheGitAI browser bridge',
|
|
96
|
+
path: launcherPath,
|
|
97
|
+
type: 'stdio',
|
|
98
|
+
allowed_origins: EXTENSION_IDS.map((id) => `chrome-extension://${id}/`),
|
|
99
|
+
};
|
|
100
|
+
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
101
|
+
if (platform === 'win32') {
|
|
102
|
+
return registerWindowsNativeHost({ env, serialized, launcherPath, changed });
|
|
103
|
+
}
|
|
104
|
+
for (const root of browserConfigRoots(env, platform)) {
|
|
105
|
+
if (!fs.existsSync(root))
|
|
106
|
+
continue;
|
|
107
|
+
const dir = path.join(root, 'NativeMessagingHosts');
|
|
108
|
+
const file = path.join(dir, `${NATIVE_HOST_NAME}.json`);
|
|
109
|
+
try {
|
|
110
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
111
|
+
const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
112
|
+
if (existing !== serialized) {
|
|
113
|
+
fs.writeFileSync(file, serialized, { mode: 0o600 });
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
manifests.push(file);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
errors.push(`${file}: ${error?.message ?? error}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { manifests, launcherPath, changed, errors };
|
|
123
|
+
}
|
|
124
|
+
const WINDOWS_REGISTRY_ROOTS = [
|
|
125
|
+
{ userData: 'Google\\Chrome', key: 'Software\\Google\\Chrome' },
|
|
126
|
+
{ userData: 'Google\\Chrome Beta', key: 'Software\\Google\\Chrome Beta' },
|
|
127
|
+
{ userData: 'Chromium', key: 'Software\\Chromium' },
|
|
128
|
+
{ userData: 'Microsoft\\Edge', key: 'Software\\Microsoft\\Edge' },
|
|
129
|
+
{ userData: 'BraveSoftware\\Brave-Browser', key: 'Software\\BraveSoftware\\Brave-Browser' },
|
|
130
|
+
{ userData: 'Vivaldi', key: 'Software\\Vivaldi' },
|
|
131
|
+
];
|
|
132
|
+
function registerWindowsNativeHost({ env, serialized, launcherPath, changed, }) {
|
|
133
|
+
const errors = [];
|
|
134
|
+
const manifests = [];
|
|
135
|
+
let dirty = changed;
|
|
136
|
+
const manifestPath = path.join(thegitaiHome(env, 'win32'), `${NATIVE_HOST_NAME}.json`);
|
|
137
|
+
try {
|
|
138
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
139
|
+
const existing = fs.existsSync(manifestPath)
|
|
140
|
+
? fs.readFileSync(manifestPath, 'utf8')
|
|
141
|
+
: null;
|
|
142
|
+
if (existing !== serialized) {
|
|
143
|
+
fs.writeFileSync(manifestPath, serialized);
|
|
144
|
+
dirty = true;
|
|
145
|
+
}
|
|
146
|
+
manifests.push(manifestPath);
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
errors.push(`${manifestPath}: ${error?.message ?? error}`);
|
|
150
|
+
return { manifests, launcherPath, changed: dirty, errors };
|
|
151
|
+
}
|
|
152
|
+
const local = env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
153
|
+
for (const browser of WINDOWS_REGISTRY_ROOTS) {
|
|
154
|
+
if (!fs.existsSync(path.join(local, browser.userData, 'User Data')))
|
|
155
|
+
continue;
|
|
156
|
+
const key = `HKCU\\${browser.key}\\NativeMessagingHosts\\${NATIVE_HOST_NAME}`;
|
|
157
|
+
try {
|
|
158
|
+
const current = readRegistryDefault(key);
|
|
159
|
+
if (current === manifestPath)
|
|
160
|
+
continue;
|
|
161
|
+
execFileSync('reg.exe', ['add', key, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f'], { timeout: 5000, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
162
|
+
dirty = true;
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
errors.push(`${key}: ${error?.message ?? error}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { manifests, launcherPath, changed: dirty, errors };
|
|
169
|
+
}
|
|
170
|
+
function readRegistryDefault(key) {
|
|
171
|
+
try {
|
|
172
|
+
const output = execFileSync('reg.exe', ['query', key, '/ve'], {
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
timeout: 5000,
|
|
175
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
176
|
+
});
|
|
177
|
+
const match = /\(Default\)\s+REG_SZ\s+(.+?)\s*$/m.exec(output);
|
|
178
|
+
return match ? match[1] : null;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export function chromiumProcessRunning(platform = process.platform) {
|
|
185
|
+
if (platform === 'win32') {
|
|
186
|
+
try {
|
|
187
|
+
const output = execFileSync('tasklist.exe', ['/fo', 'csv', '/nh'], {
|
|
188
|
+
encoding: 'utf8',
|
|
189
|
+
timeout: 4000,
|
|
190
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
191
|
+
});
|
|
192
|
+
return /"(chrome|chromium|msedge|brave|vivaldi)\.exe"/i.test(output);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const output = execFileSync('ps', ['-eo', 'comm='], {
|
|
200
|
+
encoding: 'utf8',
|
|
201
|
+
timeout: 2000,
|
|
202
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
203
|
+
});
|
|
204
|
+
return /(google[- ]?chrome|chromium|brave|msedge|microsoft edge|vivaldi)/i.test(output);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
export const BROWSER_PROTOCOL_VERSION = 1;
|
|
4
|
+
export const NATIVE_HOST_NAME = 'ai.thegit.browser_bridge';
|
|
5
|
+
export const EXTENSION_IDS = [
|
|
6
|
+
'neleeiihdnddldafklledbkembkfagol',
|
|
7
|
+
'jipbjphlelfilkhdjkfdficafjhgjfmk',
|
|
8
|
+
];
|
|
9
|
+
export function browserRuntimeDir(env = process.env, platform = process.platform) {
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
const local = env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local');
|
|
12
|
+
return path.join(env.THEGITAI_HOME ?? path.join(local, 'thegitai'), 'browser');
|
|
13
|
+
}
|
|
14
|
+
const runtime = env.XDG_RUNTIME_DIR;
|
|
15
|
+
if (runtime && path.isAbsolute(runtime)) {
|
|
16
|
+
return path.join(runtime, 'thegitai', 'browser');
|
|
17
|
+
}
|
|
18
|
+
return path.join(env.THEGITAI_HOME ?? path.join(os.homedir(), '.thegitai'), 'browser');
|
|
19
|
+
}
|
|
20
|
+
const WINDOWS_PIPE_PREFIX = '\\\\.\\pipe\\';
|
|
21
|
+
export function sessionSocketPath(dir, sessionKey, platform = process.platform) {
|
|
22
|
+
if (platform === 'win32') {
|
|
23
|
+
return `${WINDOWS_PIPE_PREFIX}thegitai-browser-${sessionKey}`;
|
|
24
|
+
}
|
|
25
|
+
return path.join(dir, `s-${sessionKey}.sock`);
|
|
26
|
+
}
|
|
27
|
+
export function socketIsFile(platform = process.platform) {
|
|
28
|
+
return platform !== 'win32';
|
|
29
|
+
}
|
|
30
|
+
export function sessionKeyFromRecordFile(fileName) {
|
|
31
|
+
const match = /^s-(.+)\.json$/.exec(fileName);
|
|
32
|
+
return match ? match[1] : null;
|
|
33
|
+
}
|
|
34
|
+
export function sessionRecordPath(dir, sessionKey) {
|
|
35
|
+
return path.join(dir, `s-${sessionKey}.json`);
|
|
36
|
+
}
|
|
37
|
+
export function protocolMismatchMessage(theirs) {
|
|
38
|
+
const version = Number(theirs);
|
|
39
|
+
if (!Number.isInteger(version)) {
|
|
40
|
+
return 'The TheGitAI extension did not report a protocol version. Update both TheGitAI and the extension.';
|
|
41
|
+
}
|
|
42
|
+
if (version < BROWSER_PROTOCOL_VERSION) {
|
|
43
|
+
return `The TheGitAI extension speaks browser protocol v${version}; this CLI speaks v${BROWSER_PROTOCOL_VERSION}. Update the extension from the Chrome Web Store.`;
|
|
44
|
+
}
|
|
45
|
+
return `The TheGitAI extension speaks browser protocol v${version}; this CLI speaks v${BROWSER_PROTOCOL_VERSION}. Update TheGitAI: npm i -g @thegitai/cli`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getClientStateDir } from '../client-state.js';
|
|
5
|
+
import { pruneStaleSessions, startBrowserBridge, } from './bridge.js';
|
|
6
|
+
import { chromiumProcessRunning } from './native-host.js';
|
|
7
|
+
let bridge = null;
|
|
8
|
+
let recentEvents = [];
|
|
9
|
+
const processKey = `${process.pid.toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
10
|
+
export function ensureBrowserBridge(options) {
|
|
11
|
+
if (bridge)
|
|
12
|
+
return bridge;
|
|
13
|
+
const env = options.env ?? process.env;
|
|
14
|
+
pruneStaleSessions(env);
|
|
15
|
+
bridge = startBrowserBridge({
|
|
16
|
+
sessionKey: processKey,
|
|
17
|
+
label: path.basename(options.cwd) || 'TheGitAI',
|
|
18
|
+
cwd: options.cwd,
|
|
19
|
+
env,
|
|
20
|
+
});
|
|
21
|
+
bridge.onEvent((event, data) => {
|
|
22
|
+
if (event === 'connected')
|
|
23
|
+
rememberBrowserExtensionSeen(env);
|
|
24
|
+
recentEvents.push({ event, data, at: Date.now() });
|
|
25
|
+
if (recentEvents.length > 50)
|
|
26
|
+
recentEvents.splice(0, recentEvents.length - 50);
|
|
27
|
+
});
|
|
28
|
+
return bridge;
|
|
29
|
+
}
|
|
30
|
+
function browserExtensionMarkerPath(env) {
|
|
31
|
+
return path.join(getClientStateDir(env), 'browser-extension-seen');
|
|
32
|
+
}
|
|
33
|
+
function rememberBrowserExtensionSeen(env) {
|
|
34
|
+
try {
|
|
35
|
+
const marker = browserExtensionMarkerPath(env);
|
|
36
|
+
if (fs.existsSync(marker))
|
|
37
|
+
return;
|
|
38
|
+
fs.mkdirSync(path.dirname(marker), { recursive: true });
|
|
39
|
+
fs.writeFileSync(marker, `${new Date().toISOString()}\n`);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function browserExtensionSeen(env = process.env) {
|
|
45
|
+
try {
|
|
46
|
+
return fs.existsSync(browserExtensionMarkerPath(env));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function startBrowserDiscoveryInBackground(options) {
|
|
53
|
+
setImmediate(() => {
|
|
54
|
+
try {
|
|
55
|
+
ensureBrowserBridge(options);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function currentBrowserBridge() {
|
|
62
|
+
return bridge;
|
|
63
|
+
}
|
|
64
|
+
export function releaseBrowserAfterTurn() {
|
|
65
|
+
const active = bridge;
|
|
66
|
+
if (!active?.status().connected)
|
|
67
|
+
return;
|
|
68
|
+
void active
|
|
69
|
+
.request('release', {}, { timeoutMs: 5000 })
|
|
70
|
+
.catch(() => {
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function closeBrowserBridge() {
|
|
74
|
+
bridge?.close();
|
|
75
|
+
bridge = null;
|
|
76
|
+
recentEvents = [];
|
|
77
|
+
}
|
|
78
|
+
export function drainBrowserEvents() {
|
|
79
|
+
const events = recentEvents.map(({ event, data }) => ({ event, data }));
|
|
80
|
+
recentEvents = [];
|
|
81
|
+
return events;
|
|
82
|
+
}
|
|
83
|
+
export function browserAvailability(options) {
|
|
84
|
+
const active = ensureBrowserBridge(options);
|
|
85
|
+
const status = active.status();
|
|
86
|
+
if (status.connected) {
|
|
87
|
+
return {
|
|
88
|
+
connected: true,
|
|
89
|
+
browsers: status.browsers,
|
|
90
|
+
selected: status.selected,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const hint = status.registrationErrors.length
|
|
94
|
+
? `TheGitAI could not register its browser bridge with Chrome: ${status.registrationErrors[0]}`
|
|
95
|
+
: chromiumProcessRunning()
|
|
96
|
+
? 'Chrome is running but the TheGitAI extension has not connected. Install or enable the extension, then reload it from chrome://extensions.'
|
|
97
|
+
: 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension installed.';
|
|
98
|
+
return {
|
|
99
|
+
connected: false,
|
|
100
|
+
browsers: [],
|
|
101
|
+
selected: null,
|
|
102
|
+
hint,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/dist/src/cli-args.js
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
|
|
2
|
+
function hasPromptWordsAfter(args) {
|
|
3
|
+
for (let i = 1; i < args.length; i++) {
|
|
4
|
+
const arg = args[i];
|
|
5
|
+
if (arg === '--session' || arg === '--resume') {
|
|
6
|
+
i += 1;
|
|
7
|
+
continue;
|
|
8
|
+
}
|
|
9
|
+
if (arg.startsWith('-'))
|
|
10
|
+
continue;
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
2
15
|
export function parseArgs(argv) {
|
|
3
16
|
const args = argv.slice(2);
|
|
4
17
|
const firstArg = args[0];
|
|
5
|
-
const command = firstArg && AUTH_COMMANDS.has(firstArg)
|
|
6
|
-
|
|
18
|
+
const command = firstArg && AUTH_COMMANDS.has(firstArg) &&
|
|
19
|
+
(firstArg !== 'login' || !hasPromptWordsAfter(args))
|
|
20
|
+
? firstArg
|
|
21
|
+
: null;
|
|
7
22
|
let autoYes = false;
|
|
8
23
|
let help = false;
|
|
9
24
|
let version = false;
|
|
@@ -12,7 +27,7 @@ export function parseArgs(argv) {
|
|
|
12
27
|
let listSessions = false;
|
|
13
28
|
let unknownOption = null;
|
|
14
29
|
const promptParts = [];
|
|
15
|
-
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
for (let i = command ? 1 : 0; i < args.length; i++) {
|
|
16
31
|
const arg = args[i];
|
|
17
32
|
if (arg === '--yes' || arg === '-y') {
|
|
18
33
|
autoYes = true;
|
|
@@ -39,7 +54,7 @@ export function parseArgs(argv) {
|
|
|
39
54
|
usage = true;
|
|
40
55
|
continue;
|
|
41
56
|
}
|
|
42
|
-
if (
|
|
57
|
+
if (unknownOption === null && /^-/.test(arg)) {
|
|
43
58
|
unknownOption = arg;
|
|
44
59
|
continue;
|
|
45
60
|
}
|
|
@@ -47,7 +62,6 @@ export function parseArgs(argv) {
|
|
|
47
62
|
}
|
|
48
63
|
return {
|
|
49
64
|
command,
|
|
50
|
-
commandArgs,
|
|
51
65
|
autoYes,
|
|
52
66
|
help,
|
|
53
67
|
version,
|
|
@@ -2,6 +2,7 @@ import { accessSync, constants, readFileSync } from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ensureSessionScratchDir } from './scratch-dir.js';
|
|
5
|
+
import { browserExtensionSeen } from './browser/session-bridge.js';
|
|
5
6
|
const PACKAGE_MANAGER_CANDIDATES = [
|
|
6
7
|
'apt',
|
|
7
8
|
'apt-get',
|
|
@@ -125,5 +126,6 @@ export function collectClientEnvironment(options = {}) {
|
|
|
125
126
|
...linuxDistro,
|
|
126
127
|
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
127
128
|
scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
|
|
129
|
+
browserExtensionSeen: options.browserExtensionSeen ?? browserExtensionSeen(env),
|
|
128
130
|
};
|
|
129
131
|
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
const MIME_BY_EXT = {
|
|
6
|
-
'.png': 'image/png',
|
|
7
|
-
'.jpg': 'image/jpeg',
|
|
8
|
-
'.jpeg': 'image/jpeg',
|
|
9
|
-
'.gif': 'image/gif',
|
|
10
|
-
'.webp': 'image/webp',
|
|
11
|
-
};
|
|
12
|
-
const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
|
|
4
|
+
import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
|
|
13
5
|
export class ClipboardError extends Error {
|
|
14
6
|
code;
|
|
15
7
|
constructor(message, code) {
|
|
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
|
|
|
273
265
|
if (stat.size > MAX_IMAGE_SIZE_BYTES) {
|
|
274
266
|
throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
|
|
275
267
|
}
|
|
276
|
-
const
|
|
277
|
-
const mimeType =
|
|
268
|
+
const buf = readFileSync(resolved);
|
|
269
|
+
const mimeType = sniffImageMimeType(buf);
|
|
278
270
|
if (!mimeType) {
|
|
279
|
-
|
|
271
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
272
|
+
throw new ClipboardError(ext
|
|
273
|
+
? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
|
|
274
|
+
: `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
|
|
280
275
|
}
|
|
281
|
-
const buf = readFileSync(resolved);
|
|
282
276
|
return { base64Data: buf.toString('base64'), mimeType };
|
|
283
277
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const MAX_IMAGES_PER_MESSAGE = 5;
|
|
2
|
+
export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
|
|
4
|
+
export function approximateBase64DecodedBytes(base64) {
|
|
5
|
+
const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
|
|
6
|
+
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
|
7
|
+
}
|
|
8
|
+
export function totalAttachmentBytes(attachments) {
|
|
9
|
+
return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
|
|
10
|
+
}
|
|
11
|
+
export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
|
|
12
|
+
'image/png',
|
|
13
|
+
'image/jpeg',
|
|
14
|
+
'image/gif',
|
|
15
|
+
'image/webp',
|
|
16
|
+
]);
|
|
17
|
+
export const IMAGE_MIME_BY_EXT = {
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.jpg': 'image/jpeg',
|
|
20
|
+
'.jpeg': 'image/jpeg',
|
|
21
|
+
'.gif': 'image/gif',
|
|
22
|
+
'.webp': 'image/webp',
|
|
23
|
+
};
|
|
24
|
+
export const IMAGE_EXT_BY_MIME = {
|
|
25
|
+
'image/png': '.png',
|
|
26
|
+
'image/jpeg': '.jpg',
|
|
27
|
+
'image/gif': '.gif',
|
|
28
|
+
'image/webp': '.webp',
|
|
29
|
+
};
|
|
30
|
+
export function isSupportedImageMimeType(mime) {
|
|
31
|
+
return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
|
|
32
|
+
}
|
|
33
|
+
export function sniffImageMimeType(bytes) {
|
|
34
|
+
if (bytes.length < 12)
|
|
35
|
+
return null;
|
|
36
|
+
if (bytes[0] === 0x89 &&
|
|
37
|
+
bytes[1] === 0x50 &&
|
|
38
|
+
bytes[2] === 0x4e &&
|
|
39
|
+
bytes[3] === 0x47) {
|
|
40
|
+
return 'image/png';
|
|
41
|
+
}
|
|
42
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
43
|
+
return 'image/jpeg';
|
|
44
|
+
}
|
|
45
|
+
if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
|
|
46
|
+
return 'image/gif';
|
|
47
|
+
}
|
|
48
|
+
if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
|
|
49
|
+
bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
|
|
50
|
+
return 'image/webp';
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
export function imageExtensionForMime(mime) {
|
|
55
|
+
return IMAGE_EXT_BY_MIME[mime] ?? '.png';
|
|
56
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'node:fs';
|
|
1
|
+
import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { loadImageFromFile } from './clipboard.js';
|
|
5
|
+
import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
|
|
6
|
+
import { tryCacheAttachmentBytes } from './session-image-store.js';
|
|
5
7
|
const EXT = '(?:png|jpe?g|gif|webp)';
|
|
6
8
|
const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
|
|
7
9
|
const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
|
|
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
|
|
|
110
112
|
}
|
|
111
113
|
return rawsByPath;
|
|
112
114
|
}
|
|
115
|
+
const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
|
|
116
|
+
`|'([^']*[\\\\/][^']*)'` +
|
|
117
|
+
`|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
|
|
118
|
+
function sniffFileHeader(resolvedPath) {
|
|
119
|
+
let fd = null;
|
|
120
|
+
try {
|
|
121
|
+
const stat = statSync(resolvedPath, { throwIfNoEntry: false });
|
|
122
|
+
if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
|
|
123
|
+
return null;
|
|
124
|
+
fd = openSync(resolvedPath, 'r');
|
|
125
|
+
const header = Buffer.alloc(12);
|
|
126
|
+
const read = readSync(fd, header, 0, 12, 0);
|
|
127
|
+
return read === 12 ? header : null;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
if (fd !== null) {
|
|
134
|
+
try {
|
|
135
|
+
closeSync(fd);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
|
|
143
|
+
const found = new Map();
|
|
144
|
+
const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
|
|
145
|
+
let match;
|
|
146
|
+
while ((match = regex.exec(input)) !== null) {
|
|
147
|
+
const raw = match[0];
|
|
148
|
+
const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
|
|
149
|
+
if (!inner || inner.includes('://'))
|
|
150
|
+
continue;
|
|
151
|
+
if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
|
|
152
|
+
continue;
|
|
153
|
+
const resolvedPath = path.isAbsolute(inner)
|
|
154
|
+
? inner
|
|
155
|
+
: path.resolve(cwd, inner);
|
|
156
|
+
if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
|
|
157
|
+
continue;
|
|
158
|
+
const header = sniffFileHeader(resolvedPath);
|
|
159
|
+
if (!header || !sniffImageMimeType(header))
|
|
160
|
+
continue;
|
|
161
|
+
found.set(resolvedPath, [raw]);
|
|
162
|
+
}
|
|
163
|
+
return found;
|
|
164
|
+
}
|
|
113
165
|
export function autoAttachImages(input, cwd, existing = []) {
|
|
114
|
-
const max =
|
|
166
|
+
const max = MAX_IMAGES_PER_MESSAGE;
|
|
115
167
|
const rawsByPath = detectImagePaths(input, cwd);
|
|
168
|
+
for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
|
|
169
|
+
rawsByPath.set(resolvedPath, rawForms);
|
|
170
|
+
}
|
|
116
171
|
let sanitizedInput = input;
|
|
117
172
|
const attachments = [];
|
|
118
173
|
const errors = [];
|
|
119
174
|
const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
|
|
175
|
+
let budgetUsed = totalAttachmentBytes(existing);
|
|
120
176
|
for (const [resolvedPath, rawForms] of rawsByPath) {
|
|
121
177
|
if (existing.length + attachments.length >= max)
|
|
122
178
|
break;
|
|
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
|
|
|
124
180
|
continue;
|
|
125
181
|
try {
|
|
126
182
|
const loaded = loadImageFromFile(resolvedPath);
|
|
127
|
-
const
|
|
183
|
+
const bytes = approximateBase64DecodedBytes(loaded.base64Data);
|
|
184
|
+
if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
|
|
185
|
+
errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
budgetUsed += bytes;
|
|
189
|
+
const cached = tryCacheAttachmentBytes({
|
|
190
|
+
base64Data: loaded.base64Data,
|
|
191
|
+
mimeType: loaded.mimeType,
|
|
192
|
+
});
|
|
193
|
+
const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
|
|
128
194
|
attachments.push({
|
|
129
195
|
index: idx,
|
|
130
196
|
mimeType: loaded.mimeType,
|
|
131
197
|
base64Data: loaded.base64Data,
|
|
132
198
|
source: 'file',
|
|
133
199
|
filePath: resolvedPath,
|
|
200
|
+
...(cached ? { cachePath: cached.cachePath } : {}),
|
|
134
201
|
});
|
|
135
202
|
for (const raw of rawForms) {
|
|
136
203
|
sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
|