@thegitai/cli 1.0.0-beta.5 → 1.0.0-beta.7
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 +1 -0
- package/dist/bin/ai.js +13 -49
- package/dist/src/api/chat.js +2 -0
- package/dist/src/artifact-policy.js +9 -0
- package/dist/src/cli-args.js +65 -0
- package/dist/src/client-environment.js +127 -0
- package/dist/src/edit-journal.js +39 -6
- package/dist/src/help-text.js +14 -0
- package/dist/src/patcher.js +17 -0
- package/dist/src/scanner.js +8 -5
- package/dist/src/session-safety.js +64 -12
- package/dist/src/tool-executor.js +8 -0
- package/dist/src/tools/index.js +2 -0
- package/dist/src/tools/patch-file.js +13 -0
- package/dist/src/tools/read-document.js +6 -2
- package/dist/src/tools/read-file.js +9 -0
- package/dist/src/tools/replace-document-text.js +175 -0
- package/dist/src/tools/str-replace.js +13 -0
- package/dist/src/tools/undo-edit.js +6 -4
- package/dist/src/tools/write-file.js +13 -0
- package/dist/src/ui/repl.js +13 -1
- package/dist/src/version.js +35 -0
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ ai login sign in via your browser (--no-browser for SSH/headless)
|
|
|
20
20
|
ai whoami show the signed-in account
|
|
21
21
|
ai --usage show account usage and reset times
|
|
22
22
|
ai logout sign out
|
|
23
|
+
ai --version print the version and exit
|
|
23
24
|
```
|
|
24
25
|
|
|
25
26
|
Run `ai --help` for sessions, modes, keys, and chat commands.
|
package/dist/bin/ai.js
CHANGED
|
@@ -13,59 +13,13 @@ import { runClientInteractive, shouldUseClientRatatuiShell, } from '../src/ui/re
|
|
|
13
13
|
import { appendPromptToFile } from '../src/ui/prompt-history-store.js';
|
|
14
14
|
import { formatSessionExitNotice } from '../src/session-exit.js';
|
|
15
15
|
import { formatUsageText } from '../src/usage.js';
|
|
16
|
+
import { formatVersionLine } from '../src/version.js';
|
|
17
|
+
import { parseArgs } from '../src/cli-args.js';
|
|
16
18
|
const DEFAULT_SERVER_URL = 'https://thegit.ai';
|
|
17
|
-
const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
|
|
18
19
|
const { auth, chat, models, sessions } = ServerApi;
|
|
19
20
|
function printUsage() {
|
|
20
21
|
console.log(formatCliHelpText({ color: process.stdout.isTTY === true }));
|
|
21
22
|
}
|
|
22
|
-
export function parseArgs(argv) {
|
|
23
|
-
const args = argv.slice(2);
|
|
24
|
-
const firstArg = args[0];
|
|
25
|
-
const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
|
|
26
|
-
const commandArgs = command ? args.slice(1) : [];
|
|
27
|
-
let autoYes = false;
|
|
28
|
-
let help = false;
|
|
29
|
-
let usage = false;
|
|
30
|
-
let session = null;
|
|
31
|
-
let listSessions = false;
|
|
32
|
-
const promptParts = [];
|
|
33
|
-
for (let i = 0; i < args.length; i++) {
|
|
34
|
-
const arg = args[i];
|
|
35
|
-
if (arg === '--yes' || arg === '-y') {
|
|
36
|
-
autoYes = true;
|
|
37
|
-
continue;
|
|
38
|
-
}
|
|
39
|
-
if ((arg === '--session' || arg === '--resume') && i + 1 < args.length) {
|
|
40
|
-
session = args[i + 1] ?? null;
|
|
41
|
-
i += 1;
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
if (arg === '--list-sessions') {
|
|
45
|
-
listSessions = true;
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
48
|
-
if (arg === '--help' || arg === '-h') {
|
|
49
|
-
help = true;
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
if (arg === '--usage') {
|
|
53
|
-
usage = true;
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
promptParts.push(arg);
|
|
57
|
-
}
|
|
58
|
-
return {
|
|
59
|
-
command,
|
|
60
|
-
commandArgs,
|
|
61
|
-
autoYes,
|
|
62
|
-
help,
|
|
63
|
-
usage,
|
|
64
|
-
session,
|
|
65
|
-
listSessions,
|
|
66
|
-
prompt: promptParts.join(' ').trim(),
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
23
|
function commandFlagValue(args, name) {
|
|
70
24
|
const index = args.indexOf(name);
|
|
71
25
|
if (index === -1)
|
|
@@ -340,11 +294,21 @@ async function mainInteractive({ authConfig, projectIndex, serverModels, serverS
|
|
|
340
294
|
}
|
|
341
295
|
}
|
|
342
296
|
export async function main() {
|
|
343
|
-
const { autoYes, help, usage, command, commandArgs, session: sessionIdentifier, listSessions, prompt, } = parseArgs(process.argv);
|
|
297
|
+
const { autoYes, help, version, usage, command, commandArgs, session: sessionIdentifier, listSessions, unknownOption, prompt, } = parseArgs(process.argv);
|
|
298
|
+
if (version) {
|
|
299
|
+
console.log(formatVersionLine());
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
344
302
|
if (help) {
|
|
345
303
|
printUsage();
|
|
346
304
|
return;
|
|
347
305
|
}
|
|
306
|
+
if (unknownOption) {
|
|
307
|
+
console.error(`Unknown option: ${unknownOption}`);
|
|
308
|
+
console.error("Run 'ai --help' to see available commands and options.");
|
|
309
|
+
process.exitCode = 2;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
348
312
|
if (command) {
|
|
349
313
|
await runAuthCommand(command, commandArgs);
|
|
350
314
|
return;
|
package/dist/src/api/chat.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createPromptCheckpoint, sanitizeSessionSafetyForServer, } from '../sess
|
|
|
2
2
|
import { applySessionSnapshot, snapshotFromSession, } from '../session-store.js';
|
|
3
3
|
import { executeLocalToolCall } from '../tool-executor.js';
|
|
4
4
|
import { createTraceContext, normalizeServerUrl, readErrorResponse, } from './http.js';
|
|
5
|
+
import { collectClientEnvironment } from '../client-environment.js';
|
|
5
6
|
export class TurnCancelledError extends Error {
|
|
6
7
|
name = 'TurnCancelledError';
|
|
7
8
|
constructor(message = 'Turn cancelled.') {
|
|
@@ -288,6 +289,7 @@ export async function sendServerUserMessage({ config, projectIndex, session, inp
|
|
|
288
289
|
modelId: session.modelId,
|
|
289
290
|
session: snapshotForServer(session),
|
|
290
291
|
input,
|
|
292
|
+
clientEnvironment: collectClientEnvironment({ env: session.env }),
|
|
291
293
|
imageAttachments,
|
|
292
294
|
maxToolSteps: session.maxToolSteps,
|
|
293
295
|
autoYes: session.autoYes,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
|
|
2
|
+
export function parseArgs(argv) {
|
|
3
|
+
const args = argv.slice(2);
|
|
4
|
+
const firstArg = args[0];
|
|
5
|
+
const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
|
|
6
|
+
const commandArgs = command ? args.slice(1) : [];
|
|
7
|
+
let autoYes = false;
|
|
8
|
+
let help = false;
|
|
9
|
+
let version = false;
|
|
10
|
+
let usage = false;
|
|
11
|
+
let session = null;
|
|
12
|
+
let listSessions = false;
|
|
13
|
+
let unknownOption = null;
|
|
14
|
+
const promptParts = [];
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
const arg = args[i];
|
|
17
|
+
if (arg === '--yes' || arg === '-y') {
|
|
18
|
+
autoYes = true;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if ((arg === '--session' || arg === '--resume') && i + 1 < args.length) {
|
|
22
|
+
session = args[i + 1] ?? null;
|
|
23
|
+
i += 1;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (arg === '--list-sessions') {
|
|
27
|
+
listSessions = true;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--help' || arg === '-h') {
|
|
31
|
+
help = true;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (arg === '--version' || arg === '-v') {
|
|
35
|
+
version = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (arg === '--usage') {
|
|
39
|
+
usage = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
// An unrecognized dashed token is a mistyped flag, not prompt text. Without
|
|
43
|
+
// an auth subcommand (whose flags are parsed separately) it would otherwise
|
|
44
|
+
// be swept into the prompt and silently start a billable session. Flag the
|
|
45
|
+
// first one so the caller can fail fast instead. Quoted prompts are a single
|
|
46
|
+
// argv entry with spaces, so they never look like a bare option here.
|
|
47
|
+
if (command === null && unknownOption === null && /^-/.test(arg)) {
|
|
48
|
+
unknownOption = arg;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
promptParts.push(arg);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
command,
|
|
55
|
+
commandArgs,
|
|
56
|
+
autoYes,
|
|
57
|
+
help,
|
|
58
|
+
version,
|
|
59
|
+
usage,
|
|
60
|
+
session,
|
|
61
|
+
listSessions,
|
|
62
|
+
unknownOption,
|
|
63
|
+
prompt: promptParts.join(' ').trim(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const PACKAGE_MANAGER_CANDIDATES = [
|
|
5
|
+
'apt',
|
|
6
|
+
'apt-get',
|
|
7
|
+
'dnf',
|
|
8
|
+
'yum',
|
|
9
|
+
'pacman',
|
|
10
|
+
'zypper',
|
|
11
|
+
'apk',
|
|
12
|
+
'brew',
|
|
13
|
+
'nix',
|
|
14
|
+
'snap',
|
|
15
|
+
'flatpak',
|
|
16
|
+
'winget',
|
|
17
|
+
'choco',
|
|
18
|
+
'scoop',
|
|
19
|
+
];
|
|
20
|
+
function detectShell(platform, env) {
|
|
21
|
+
if (platform === 'win32') {
|
|
22
|
+
const comspec = env.COMSPEC;
|
|
23
|
+
return comspec ? path.win32.basename(comspec) : 'unknown';
|
|
24
|
+
}
|
|
25
|
+
const shell = env.SHELL;
|
|
26
|
+
return shell ? path.basename(shell) : 'unknown';
|
|
27
|
+
}
|
|
28
|
+
function unquoteOsReleaseValue(value) {
|
|
29
|
+
const trimmed = value.trim();
|
|
30
|
+
if (trimmed.length < 2)
|
|
31
|
+
return trimmed;
|
|
32
|
+
const quote = trimmed[0];
|
|
33
|
+
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) {
|
|
34
|
+
return trimmed;
|
|
35
|
+
}
|
|
36
|
+
return trimmed
|
|
37
|
+
.slice(1, -1)
|
|
38
|
+
.replace(/\\(["'`$\\])/g, '$1')
|
|
39
|
+
.trim();
|
|
40
|
+
}
|
|
41
|
+
export function parseLinuxOsRelease(text) {
|
|
42
|
+
const values = {};
|
|
43
|
+
for (const line of text.split(/\r?\n/)) {
|
|
44
|
+
const trimmed = line.trim();
|
|
45
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
46
|
+
continue;
|
|
47
|
+
const index = trimmed.indexOf('=');
|
|
48
|
+
if (index <= 0)
|
|
49
|
+
continue;
|
|
50
|
+
const key = trimmed.slice(0, index);
|
|
51
|
+
const value = unquoteOsReleaseValue(trimmed.slice(index + 1));
|
|
52
|
+
values[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
distroId: values.ID,
|
|
56
|
+
distroName: values.PRETTY_NAME ?? values.NAME,
|
|
57
|
+
distroVersion: values.VERSION_ID ?? values.VERSION,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function readLinuxOsReleaseText(options) {
|
|
61
|
+
if ('osReleaseText' in options) {
|
|
62
|
+
return options.osReleaseText ?? '';
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return readFileSync('/etc/os-release', 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function pathEnv(env) {
|
|
72
|
+
return env.PATH ?? env.Path ?? env.path ?? '';
|
|
73
|
+
}
|
|
74
|
+
function windowsExecutableNames(command, env) {
|
|
75
|
+
if (path.extname(command))
|
|
76
|
+
return [command];
|
|
77
|
+
const extensions = (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD')
|
|
78
|
+
.split(';')
|
|
79
|
+
.map((extension) => extension.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)];
|
|
82
|
+
}
|
|
83
|
+
function defaultExecutableExists(command, env, platform) {
|
|
84
|
+
const searchPath = pathEnv(env);
|
|
85
|
+
if (!searchPath)
|
|
86
|
+
return false;
|
|
87
|
+
const names = platform === 'win32' ? windowsExecutableNames(command, env) : [command];
|
|
88
|
+
for (const dir of searchPath.split(path.delimiter)) {
|
|
89
|
+
if (!dir.trim())
|
|
90
|
+
continue;
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
try {
|
|
93
|
+
accessSync(path.join(dir, name), constants.X_OK);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
try {
|
|
98
|
+
accessSync(path.join(dir, name));
|
|
99
|
+
return platform === 'win32';
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function detectPackageManagers(env, platform, executableExists) {
|
|
110
|
+
return PACKAGE_MANAGER_CANDIDATES.filter((command) => executableExists(command, env, platform));
|
|
111
|
+
}
|
|
112
|
+
export function collectClientEnvironment(options = {}) {
|
|
113
|
+
const platform = options.platform ?? process.platform;
|
|
114
|
+
const env = options.env ?? process.env;
|
|
115
|
+
const executableExists = options.executableExists ?? defaultExecutableExists;
|
|
116
|
+
const linuxDistro = platform === 'linux'
|
|
117
|
+
? parseLinuxOsRelease(readLinuxOsReleaseText(options))
|
|
118
|
+
: {};
|
|
119
|
+
return {
|
|
120
|
+
platform,
|
|
121
|
+
arch: options.arch ?? process.arch,
|
|
122
|
+
release: options.release ?? os.release(),
|
|
123
|
+
shell: detectShell(platform, env),
|
|
124
|
+
...linuxDistro,
|
|
125
|
+
packageManagers: detectPackageManagers(env, platform, executableExists),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/dist/src/edit-journal.js
CHANGED
|
@@ -1,33 +1,64 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { BINARY_ARTIFACT_EXTENSIONS } from './artifact-policy.js';
|
|
4
6
|
import { resolveProjectPath } from './patcher.js';
|
|
5
7
|
export const MAX_EDIT_JOURNAL_RECORDS = 50;
|
|
6
|
-
const MAX_STORED_CONTENT_CHARS =
|
|
7
|
-
export function
|
|
8
|
+
const MAX_STORED_CONTENT_CHARS = 8_000_000;
|
|
9
|
+
export function hashBytes(content) {
|
|
8
10
|
return `sha256:${createHash('sha256').update(content).digest('hex')}`;
|
|
9
11
|
}
|
|
12
|
+
export function hashContent(content) {
|
|
13
|
+
return hashBytes(Buffer.from(content, 'utf8'));
|
|
14
|
+
}
|
|
15
|
+
export function hashStoredContent(content, encoding = 'utf8') {
|
|
16
|
+
return encoding === 'base64'
|
|
17
|
+
? hashBytes(Buffer.from(content, 'base64'))
|
|
18
|
+
: hashContent(content);
|
|
19
|
+
}
|
|
20
|
+
export function storedContentBuffer(content, encoding = 'utf8') {
|
|
21
|
+
return encoding === 'base64'
|
|
22
|
+
? Buffer.from(content, 'base64')
|
|
23
|
+
: Buffer.from(content, 'utf8');
|
|
24
|
+
}
|
|
25
|
+
function snapshotEncoding(filePath, content) {
|
|
26
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
27
|
+
if (BINARY_ARTIFACT_EXTENSIONS.has(ext))
|
|
28
|
+
return 'base64';
|
|
29
|
+
if (content.includes(0))
|
|
30
|
+
return 'base64';
|
|
31
|
+
return 'utf8';
|
|
32
|
+
}
|
|
10
33
|
export function readFileEditSnapshot(rootDir, filePath) {
|
|
11
34
|
try {
|
|
12
35
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
13
36
|
if (!existsSync(absPath)) {
|
|
14
|
-
return { exists: false, content: null, hash: null };
|
|
37
|
+
return { exists: false, content: null, contentEncoding: 'utf8', hash: null };
|
|
15
38
|
}
|
|
16
39
|
if (lstatSync(absPath).isSymbolicLink()) {
|
|
17
40
|
return {
|
|
18
41
|
exists: true,
|
|
19
42
|
content: null,
|
|
43
|
+
contentEncoding: 'utf8',
|
|
20
44
|
hash: null,
|
|
21
45
|
error: `Refusing to snapshot symbolic link: ${filePath}`,
|
|
22
46
|
};
|
|
23
47
|
}
|
|
24
|
-
const content = readFileSync(absPath
|
|
25
|
-
|
|
48
|
+
const content = readFileSync(absPath);
|
|
49
|
+
const encoding = snapshotEncoding(filePath, content);
|
|
50
|
+
return {
|
|
51
|
+
exists: true,
|
|
52
|
+
content: encoding === 'base64' ? content.toString('base64') : content.toString('utf8'),
|
|
53
|
+
contentEncoding: encoding,
|
|
54
|
+
hash: hashBytes(content),
|
|
55
|
+
};
|
|
26
56
|
}
|
|
27
57
|
catch (err) {
|
|
28
58
|
return {
|
|
29
59
|
exists: false,
|
|
30
60
|
content: null,
|
|
61
|
+
contentEncoding: 'utf8',
|
|
31
62
|
hash: null,
|
|
32
63
|
error: err?.message ? String(err.message) : String(err),
|
|
33
64
|
};
|
|
@@ -46,7 +77,8 @@ export function isEditToolName(toolName) {
|
|
|
46
77
|
return (toolName === 'write_file' ||
|
|
47
78
|
toolName === 'patch_file' ||
|
|
48
79
|
toolName === 'str_replace' ||
|
|
49
|
-
toolName === 'delete_file'
|
|
80
|
+
toolName === 'delete_file' ||
|
|
81
|
+
toolName === 'replace_document_text');
|
|
50
82
|
}
|
|
51
83
|
export function operationFromSnapshots(before, after) {
|
|
52
84
|
if (before.hash === after.hash)
|
|
@@ -102,6 +134,7 @@ export function normalizeAssistantEditJournal(value) {
|
|
|
102
134
|
? entry.afterHash
|
|
103
135
|
: null,
|
|
104
136
|
beforeContent: typeof entry.beforeContent === 'string' ? entry.beforeContent : null,
|
|
137
|
+
beforeContentEncoding: entry.beforeContentEncoding === 'base64' ? 'base64' : 'utf8',
|
|
105
138
|
createdAt: typeof entry.createdAt === 'string' && entry.createdAt
|
|
106
139
|
? entry.createdAt
|
|
107
140
|
: new Date().toISOString(),
|
package/dist/src/help-text.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { getCliVersion, getPlatformTag } from './version.js';
|
|
2
3
|
// The bound keys (Enter/Esc/Ctrl+C/Tab/arrows) are identical across platforms in
|
|
3
4
|
// a terminal. The one thing that genuinely differs is the terminal's paste
|
|
4
5
|
// shortcut, so surface the one for the host OS (right-click paste works
|
|
@@ -45,6 +46,7 @@ const HELP_MARKDOWN = [
|
|
|
45
46
|
'',
|
|
46
47
|
'- `-y, --yes` — start in Auto-Accept mode',
|
|
47
48
|
'- `-h, --help` — show this help',
|
|
49
|
+
'- `-v, --version` — print the version and exit',
|
|
48
50
|
'',
|
|
49
51
|
'## Modes',
|
|
50
52
|
'',
|
|
@@ -70,6 +72,7 @@ const HELP_MARKDOWN = [
|
|
|
70
72
|
'## Chat commands',
|
|
71
73
|
'',
|
|
72
74
|
'- `/help` — show this help',
|
|
75
|
+
'- `/about` — show version and platform info',
|
|
73
76
|
'- `/usage` — show account usage percentage and reset times',
|
|
74
77
|
'- `/model` — list supported models and pick one',
|
|
75
78
|
'- `/model <id>` — switch the active model without clearing history',
|
|
@@ -100,6 +103,17 @@ const HELP_MARKDOWN = [
|
|
|
100
103
|
'- For anything else, re-run the command and report the printed error',
|
|
101
104
|
' message — there is no client-side debug mode by design.',
|
|
102
105
|
].join('\n');
|
|
106
|
+
export function formatAboutCard() {
|
|
107
|
+
// Fenced so the column alignment survives terminal markdown rendering.
|
|
108
|
+
return [
|
|
109
|
+
'```',
|
|
110
|
+
'TheGitAI',
|
|
111
|
+
` Version ${getCliVersion()}`,
|
|
112
|
+
` Platform ${getPlatformTag()}`,
|
|
113
|
+
` Node ${process.version}`,
|
|
114
|
+
'```',
|
|
115
|
+
].join('\n');
|
|
116
|
+
}
|
|
103
117
|
export function formatHelpMarkdown() {
|
|
104
118
|
return HELP_MARKDOWN;
|
|
105
119
|
}
|
package/dist/src/patcher.js
CHANGED
|
@@ -169,6 +169,23 @@ export function writeProjectFile(rootDir, filePath, content) {
|
|
|
169
169
|
writeFileSync(absPath, content, 'utf-8');
|
|
170
170
|
return { absPath, changed: true };
|
|
171
171
|
}
|
|
172
|
+
export function writeProjectFileBuffer(rootDir, filePath, content) {
|
|
173
|
+
const absPath = resolveProjectPath(rootDir, filePath);
|
|
174
|
+
if (existsSync(absPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const existingContent = readFileSync(absPath);
|
|
177
|
+
if (existingContent.equals(content)) {
|
|
178
|
+
return { absPath, changed: false };
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// If we can't read it for some reason, proceed with write
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
mkdirSync(path.dirname(absPath), { recursive: true });
|
|
186
|
+
writeFileSync(absPath, content);
|
|
187
|
+
return { absPath, changed: true };
|
|
188
|
+
}
|
|
172
189
|
export function deleteProjectFile(rootDir, filePath) {
|
|
173
190
|
const absPath = resolveProjectPath(rootDir, filePath);
|
|
174
191
|
if (!existsSync(absPath)) {
|
package/dist/src/scanner.js
CHANGED
|
@@ -16,13 +16,16 @@ const TARGET_CHUNK_CHARS = 1800;
|
|
|
16
16
|
const MAX_CHUNK_CHARS = 2800;
|
|
17
17
|
const FALLBACK_OVERLAP_LINES = 10;
|
|
18
18
|
const MAX_STRUCTURE_DEPTH = 2;
|
|
19
|
+
function parseGitLsFilesOutput(output) {
|
|
20
|
+
return String(output)
|
|
21
|
+
.split('\0')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.filter((filePath) => !shouldIgnorePath(filePath));
|
|
24
|
+
}
|
|
19
25
|
function getFiles(rootDir, { limit = Infinity } = {}) {
|
|
20
26
|
try {
|
|
21
|
-
const output = execSync('git ls-files --cached --others --exclude-standard', { cwd: rootDir,
|
|
22
|
-
const files = output
|
|
23
|
-
.split('\n')
|
|
24
|
-
.filter(Boolean)
|
|
25
|
-
.filter((filePath) => !shouldIgnorePath(filePath));
|
|
27
|
+
const output = execSync('git ls-files -z --cached --others --exclude-standard', { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
28
|
+
const files = parseGitLsFilesOutput(output);
|
|
26
29
|
return Number.isFinite(limit) ? files.slice(0, limit) : files;
|
|
27
30
|
}
|
|
28
31
|
catch {
|
|
@@ -2,14 +2,14 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { lstatSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ARTIFACT_IGNORE_DIRS, normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
|
|
5
|
-
import {
|
|
6
|
-
import { deleteProjectFile, resolveProjectPath, writeProjectFile } from './patcher.js';
|
|
5
|
+
import { hashBytes, readFileEditSnapshot, storedContentBuffer, } from './edit-journal.js';
|
|
6
|
+
import { deleteProjectFile, resolveProjectPath, writeProjectFile, writeProjectFileBuffer, } from './patcher.js';
|
|
7
7
|
import { removeIndexFile, upsertIndexFile } from './project-index.js';
|
|
8
8
|
const MAX_CHECKPOINTS = 20;
|
|
9
9
|
const MAX_SESSION_EDITS = 500;
|
|
10
10
|
const MAX_READ_RECORDS = 200;
|
|
11
11
|
const MAX_REDACTION_TOKENS = 500;
|
|
12
|
-
const MAX_SNAPSHOT_CONTENT_CHARS =
|
|
12
|
+
const MAX_SNAPSHOT_CONTENT_CHARS = 8_000_000;
|
|
13
13
|
const MAX_MUTATION_SCAN_FILES = 2000;
|
|
14
14
|
const MAX_BASELINE_CONTENT_BYTES = 50 * 1024 * 1024;
|
|
15
15
|
export function createSessionSafetyState() {
|
|
@@ -34,6 +34,14 @@ function normalizeEditOperation(value) {
|
|
|
34
34
|
? text
|
|
35
35
|
: null;
|
|
36
36
|
}
|
|
37
|
+
function normalizeStoredContentEncoding(value) {
|
|
38
|
+
return value === 'base64' ? 'base64' : 'utf8';
|
|
39
|
+
}
|
|
40
|
+
function writeStoredProjectFile(rootDir, filePath, content, encoding) {
|
|
41
|
+
return encoding === 'base64'
|
|
42
|
+
? writeProjectFileBuffer(rootDir, filePath, storedContentBuffer(content, encoding))
|
|
43
|
+
: writeProjectFile(rootDir, filePath, content);
|
|
44
|
+
}
|
|
37
45
|
export function normalizeSessionSafetyState(value) {
|
|
38
46
|
const raw = value && typeof value === 'object' ? value : {};
|
|
39
47
|
const safety = createSessionSafetyState();
|
|
@@ -57,6 +65,7 @@ export function normalizeSessionSafetyState(value) {
|
|
|
57
65
|
exists: file.exists === true,
|
|
58
66
|
hash: typeof file.hash === 'string' ? file.hash : null,
|
|
59
67
|
content: typeof file.content === 'string' ? file.content : null,
|
|
68
|
+
contentEncoding: normalizeStoredContentEncoding(file.contentEncoding),
|
|
60
69
|
skipped: typeof file.skipped === 'string' && file.skipped.trim()
|
|
61
70
|
? file.skipped.trim()
|
|
62
71
|
: undefined,
|
|
@@ -143,6 +152,7 @@ export function normalizeSessionSafetyState(value) {
|
|
|
143
152
|
beforeHash: typeof item.beforeHash === 'string' ? item.beforeHash : null,
|
|
144
153
|
afterHash: typeof item.afterHash === 'string' ? item.afterHash : null,
|
|
145
154
|
beforeContent: typeof item.beforeContent === 'string' ? item.beforeContent : null,
|
|
155
|
+
beforeContentEncoding: normalizeStoredContentEncoding(item.beforeContentEncoding),
|
|
146
156
|
createdAt: typeof item.createdAt === 'string' && item.createdAt
|
|
147
157
|
? item.createdAt
|
|
148
158
|
: new Date().toISOString(),
|
|
@@ -242,7 +252,10 @@ export function mergeLocalSessionSafetyState(local, incoming) {
|
|
|
242
252
|
for (const file of checkpoint.files) {
|
|
243
253
|
if (file.content == null)
|
|
244
254
|
continue;
|
|
245
|
-
localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`,
|
|
255
|
+
localCheckpointContent.set(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`, {
|
|
256
|
+
content: file.content,
|
|
257
|
+
contentEncoding: file.contentEncoding,
|
|
258
|
+
});
|
|
246
259
|
}
|
|
247
260
|
}
|
|
248
261
|
next.checkpoints = next.checkpoints.map((checkpoint) => ({
|
|
@@ -251,17 +264,35 @@ export function mergeLocalSessionSafetyState(local, incoming) {
|
|
|
251
264
|
if (file.content != null)
|
|
252
265
|
return file;
|
|
253
266
|
const content = localCheckpointContent.get(`${checkpoint.id}\0${file.filePath}\0${file.hash ?? ''}`);
|
|
254
|
-
return content == null
|
|
267
|
+
return content == null
|
|
268
|
+
? file
|
|
269
|
+
: {
|
|
270
|
+
...file,
|
|
271
|
+
content: content.content,
|
|
272
|
+
contentEncoding: content.contentEncoding,
|
|
273
|
+
};
|
|
255
274
|
}),
|
|
256
275
|
}));
|
|
257
276
|
const localBeforeContent = new Map(localState.sessionEdits
|
|
258
277
|
.filter((edit) => edit.beforeContent != null)
|
|
259
|
-
.map((edit) => [
|
|
278
|
+
.map((edit) => [
|
|
279
|
+
edit.id,
|
|
280
|
+
{
|
|
281
|
+
beforeContent: edit.beforeContent,
|
|
282
|
+
beforeContentEncoding: edit.beforeContentEncoding,
|
|
283
|
+
},
|
|
284
|
+
]));
|
|
260
285
|
next.sessionEdits = next.sessionEdits.map((edit) => {
|
|
261
286
|
if (edit.beforeContent != null)
|
|
262
287
|
return edit;
|
|
263
288
|
const beforeContent = localBeforeContent.get(edit.id);
|
|
264
|
-
return beforeContent == null
|
|
289
|
+
return beforeContent == null
|
|
290
|
+
? edit
|
|
291
|
+
: {
|
|
292
|
+
...edit,
|
|
293
|
+
beforeContent: beforeContent.beforeContent,
|
|
294
|
+
beforeContentEncoding: beforeContent.beforeContentEncoding,
|
|
295
|
+
};
|
|
265
296
|
});
|
|
266
297
|
return next;
|
|
267
298
|
}
|
|
@@ -279,6 +310,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
279
310
|
exists: false,
|
|
280
311
|
hash: null,
|
|
281
312
|
content: null,
|
|
313
|
+
contentEncoding: 'utf8',
|
|
282
314
|
skipped: `Refusing to checkpoint ignored or out-of-project path: ${filePath}`,
|
|
283
315
|
};
|
|
284
316
|
}
|
|
@@ -289,6 +321,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
289
321
|
exists: snapshot.exists,
|
|
290
322
|
hash: snapshot.hash,
|
|
291
323
|
content: null,
|
|
324
|
+
contentEncoding: snapshot.contentEncoding,
|
|
292
325
|
skipped: snapshot.error,
|
|
293
326
|
};
|
|
294
327
|
}
|
|
@@ -298,6 +331,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
298
331
|
exists: snapshot.exists,
|
|
299
332
|
hash: snapshot.hash,
|
|
300
333
|
content: null,
|
|
334
|
+
contentEncoding: snapshot.contentEncoding,
|
|
301
335
|
skipped: `File is too large to checkpoint (${snapshot.content.length} chars).`,
|
|
302
336
|
};
|
|
303
337
|
}
|
|
@@ -306,6 +340,7 @@ function readCheckpointSnapshot(rootDir, filePath) {
|
|
|
306
340
|
exists: snapshot.exists,
|
|
307
341
|
hash: snapshot.hash,
|
|
308
342
|
content: snapshot.content,
|
|
343
|
+
contentEncoding: snapshot.contentEncoding,
|
|
309
344
|
};
|
|
310
345
|
}
|
|
311
346
|
export function createPromptCheckpoint(state, label, turnId) {
|
|
@@ -661,7 +696,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
661
696
|
const before = readFileEditSnapshot(args.rootDir, snapshot.filePath);
|
|
662
697
|
try {
|
|
663
698
|
if (snapshot.exists) {
|
|
664
|
-
const result =
|
|
699
|
+
const result = writeStoredProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '', snapshot.contentEncoding);
|
|
665
700
|
if (result.changed)
|
|
666
701
|
changed = true;
|
|
667
702
|
if (syncPolicy)
|
|
@@ -693,7 +728,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
693
728
|
if (item.before.content == null) {
|
|
694
729
|
throw new Error('previous file content is unavailable');
|
|
695
730
|
}
|
|
696
|
-
|
|
731
|
+
writeStoredProjectFile(args.rootDir, item.snapshot.filePath, item.before.content, item.before.contentEncoding);
|
|
697
732
|
if (syncPolicy) {
|
|
698
733
|
await upsertIndexFile(args.projectIndex, item.snapshot.filePath);
|
|
699
734
|
}
|
|
@@ -749,6 +784,7 @@ export async function restoreCheckpointFiles(args) {
|
|
|
749
784
|
beforeHash: item.before.hash,
|
|
750
785
|
afterHash: item.after.hash,
|
|
751
786
|
beforeContent: item.before.content,
|
|
787
|
+
beforeContentEncoding: item.before.contentEncoding,
|
|
752
788
|
checkpointId: checkpoint.id,
|
|
753
789
|
});
|
|
754
790
|
restored.push({
|
|
@@ -777,6 +813,18 @@ function git(args, cwd) {
|
|
|
777
813
|
return null;
|
|
778
814
|
}
|
|
779
815
|
}
|
|
816
|
+
function gitBuffer(args, cwd) {
|
|
817
|
+
try {
|
|
818
|
+
return execFileSync('git', args, {
|
|
819
|
+
cwd,
|
|
820
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
821
|
+
timeout: 10_000,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
780
828
|
export function findGitRoot(startDir) {
|
|
781
829
|
const root = git(['rev-parse', '--show-toplevel'], startDir);
|
|
782
830
|
return root ? path.resolve(root) : null;
|
|
@@ -897,14 +945,15 @@ function readGitHeadSnapshot(rootDir, filePath) {
|
|
|
897
945
|
return null;
|
|
898
946
|
const abs = resolveProjectPath(rootDir, filePath);
|
|
899
947
|
const relToGit = path.relative(gitRoot, abs).split(path.sep).join('/');
|
|
900
|
-
const content =
|
|
948
|
+
const content = gitBuffer(['show', `HEAD:${relToGit}`], gitRoot);
|
|
901
949
|
if (content == null)
|
|
902
950
|
return null;
|
|
903
951
|
return {
|
|
904
952
|
filePath,
|
|
905
953
|
exists: true,
|
|
906
|
-
hash:
|
|
907
|
-
content,
|
|
954
|
+
hash: hashBytes(content),
|
|
955
|
+
content: content.toString('base64'),
|
|
956
|
+
contentEncoding: 'base64',
|
|
908
957
|
};
|
|
909
958
|
}
|
|
910
959
|
export function captureMutationBaseline(rootDir, options) {
|
|
@@ -932,6 +981,7 @@ export function captureMutationBaseline(rootDir, options) {
|
|
|
932
981
|
exists: true,
|
|
933
982
|
hash: null,
|
|
934
983
|
content: null,
|
|
984
|
+
contentEncoding: 'utf8',
|
|
935
985
|
skipped: `Pre-command snapshot skipped: project baseline content budget exceeded (${contentBudget} bytes). Restore for this file is not available.`,
|
|
936
986
|
});
|
|
937
987
|
continue;
|
|
@@ -978,6 +1028,7 @@ export function collectCommandMutations(args) {
|
|
|
978
1028
|
exists: false,
|
|
979
1029
|
hash: null,
|
|
980
1030
|
content: null,
|
|
1031
|
+
contentEncoding: 'utf8',
|
|
981
1032
|
};
|
|
982
1033
|
if (before.hash === after.hash && !before.skipped)
|
|
983
1034
|
continue;
|
|
@@ -1000,6 +1051,7 @@ export function collectCommandMutations(args) {
|
|
|
1000
1051
|
beforeHash: before.hash,
|
|
1001
1052
|
afterHash: after.hash,
|
|
1002
1053
|
beforeContent: before.content,
|
|
1054
|
+
beforeContentEncoding: before.contentEncoding,
|
|
1003
1055
|
checkpointId: args.checkpointId,
|
|
1004
1056
|
});
|
|
1005
1057
|
records.push(record);
|
|
@@ -32,6 +32,12 @@ export function formatToolCallForStatus(call) {
|
|
|
32
32
|
}
|
|
33
33
|
function getEditToolFilePath(call) {
|
|
34
34
|
const args = call.args && typeof call.args === 'object' ? call.args : {};
|
|
35
|
+
if (call.name === 'replace_document_text') {
|
|
36
|
+
const outputPath = args.outputPath ?? args.output_path;
|
|
37
|
+
if (typeof outputPath === 'string' && outputPath.trim()) {
|
|
38
|
+
return outputPath.trim();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
35
41
|
for (const key of EDIT_FILE_PATH_ARG_ALIASES) {
|
|
36
42
|
const value = args[key];
|
|
37
43
|
if (typeof value === 'string' && value.trim())
|
|
@@ -79,6 +85,7 @@ function recordAssistantEdit(session, call, result, before) {
|
|
|
79
85
|
beforeHash: before.hash,
|
|
80
86
|
afterHash: after.hash,
|
|
81
87
|
beforeContent: operation === 'create' ? null : before.content,
|
|
88
|
+
beforeContentEncoding: before.contentEncoding,
|
|
82
89
|
createdAt: new Date().toISOString(),
|
|
83
90
|
revertedAt: null,
|
|
84
91
|
revertedByToolCallId: null,
|
|
@@ -98,6 +105,7 @@ function recordAssistantEdit(session, call, result, before) {
|
|
|
98
105
|
beforeHash: before.hash,
|
|
99
106
|
afterHash: after.hash,
|
|
100
107
|
beforeContent: operation === 'create' ? null : before.content,
|
|
108
|
+
beforeContentEncoding: before.contentEncoding,
|
|
101
109
|
checkpointId: checkpoint.id,
|
|
102
110
|
});
|
|
103
111
|
clearEditFailure(session.clientState.safety, filePath);
|
package/dist/src/tools/index.js
CHANGED
|
@@ -11,6 +11,7 @@ import { listSymbols } from './list-symbols.js';
|
|
|
11
11
|
import { patchFile } from './patch-file.js';
|
|
12
12
|
import { readDocument } from './read-document.js';
|
|
13
13
|
import { readFile } from './read-file.js';
|
|
14
|
+
import { replaceDocumentText } from './replace-document-text.js';
|
|
14
15
|
import { runShellCommand } from './run-command.js';
|
|
15
16
|
import { runNodeScript } from './run-node-script.js';
|
|
16
17
|
import { restoreFilesToCheckpoint, restoreToCheckpoint, } from './restore-checkpoint.js';
|
|
@@ -25,6 +26,7 @@ export const TOOL_MAP = {
|
|
|
25
26
|
list_directories: (context, args) => listDirectories(context, args),
|
|
26
27
|
read_file: (context, args) => readFile(context, args),
|
|
27
28
|
read_document: (context, args) => readDocument(context.rootDir, args, context.env),
|
|
29
|
+
replace_document_text: replaceDocumentText,
|
|
28
30
|
grep_code: (context, args) => grepCode(context.rootDir, args),
|
|
29
31
|
find_symbol: (context, args) => findSymbol(context, args),
|
|
30
32
|
list_symbols: (context, args) => listSymbols(context, args),
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
export async function patchFile(context, args) {
|
|
9
11
|
const { rootDir, projectIndex, autoYes, confirmPatch } = context;
|
|
10
12
|
const filePath = String(args.filePath ?? '').trim();
|
|
@@ -15,6 +17,17 @@ export async function patchFile(context, args) {
|
|
|
15
17
|
if (!patch.trim()) {
|
|
16
18
|
return { ok: false, error: 'patch is required' };
|
|
17
19
|
}
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
filePath,
|
|
25
|
+
error: ext === '.docx'
|
|
26
|
+
? 'Use replace_document_text for .docx files.'
|
|
27
|
+
: `Use read_document for ${ext} files; patching is not supported.`,
|
|
28
|
+
failureCategory: 'invalid_argument',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
18
31
|
let originalContent;
|
|
19
32
|
try {
|
|
20
33
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -36,6 +36,7 @@ export function normalizeDocumentText(raw) {
|
|
|
36
36
|
}
|
|
37
37
|
async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
38
38
|
const includePageArgs = ext === '.pdf';
|
|
39
|
+
const includeParagraphArgs = ext === '.docx';
|
|
39
40
|
const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/parse`, {
|
|
40
41
|
method: 'POST',
|
|
41
42
|
headers: {
|
|
@@ -51,6 +52,9 @@ async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
|
|
|
51
52
|
...(includePageArgs && args.lastPage !== undefined
|
|
52
53
|
? { lastPage: args.lastPage }
|
|
53
54
|
: {}),
|
|
55
|
+
...(includeParagraphArgs && args.firstParagraph !== undefined
|
|
56
|
+
? { firstParagraph: args.firstParagraph }
|
|
57
|
+
: {}),
|
|
54
58
|
}),
|
|
55
59
|
});
|
|
56
60
|
const data = await response.json().catch(() => null);
|
|
@@ -83,10 +87,10 @@ export async function readDocument(rootDir, args, env) {
|
|
|
83
87
|
};
|
|
84
88
|
}
|
|
85
89
|
const ext = path.extname(resolvedPath).toLowerCase();
|
|
86
|
-
if (ext !== '.pdf' && ext !== '.xlsx') {
|
|
90
|
+
if (ext !== '.pdf' && ext !== '.xlsx' && ext !== '.docx') {
|
|
87
91
|
return {
|
|
88
92
|
ok: false,
|
|
89
|
-
error: `Unsupported file type: "${ext}". read_document only supports .pdf and .
|
|
93
|
+
error: `Unsupported file type: "${ext}". read_document only supports .pdf, .xlsx, and .docx.`,
|
|
90
94
|
};
|
|
91
95
|
}
|
|
92
96
|
const authConfig = readCliAuthConfig(env);
|
|
@@ -6,6 +6,7 @@ import { readProjectFile } from '../patcher.js';
|
|
|
6
6
|
import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
|
|
7
7
|
import { readFileRange, truncate } from '../utils.js';
|
|
8
8
|
const MAX_FILE_READ_CHARS = 12000;
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
9
10
|
export async function readFile(context, args) {
|
|
10
11
|
const rootDir = typeof context === 'string' ? context : context.rootDir;
|
|
11
12
|
const safety = typeof context === 'string' ? undefined : context.safety;
|
|
@@ -26,6 +27,14 @@ export async function readFile(context, args) {
|
|
|
26
27
|
error: 'This path is not permitted.',
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
const documentExt = path.extname(projectPath ?? filePath).toLowerCase();
|
|
31
|
+
if (DOCUMENT_EXTENSIONS.has(documentExt)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: `Use read_document for ${documentExt} files.`,
|
|
35
|
+
failureCategory: 'invalid_argument',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
29
38
|
let content;
|
|
30
39
|
if (projectPath) {
|
|
31
40
|
try {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
|
|
5
|
+
import { readCliAuthConfig } from '../api/auth.js';
|
|
6
|
+
import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
|
|
7
|
+
import { isTuiMode } from '../runtime-mode.js';
|
|
8
|
+
function normalizeReplacements(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return [];
|
|
11
|
+
return value
|
|
12
|
+
.map((item) => {
|
|
13
|
+
if (!item || typeof item !== 'object')
|
|
14
|
+
return null;
|
|
15
|
+
const entry = item;
|
|
16
|
+
const oldText = String(entry.oldText ?? entry.old_text ?? '');
|
|
17
|
+
const newText = String(entry.newText ?? entry.new_text ?? '');
|
|
18
|
+
if (!oldText)
|
|
19
|
+
return null;
|
|
20
|
+
return { oldText, newText };
|
|
21
|
+
})
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
function relativeEditablePath(rootDir, rawPath) {
|
|
25
|
+
const resolvedPath = path.isAbsolute(rawPath)
|
|
26
|
+
? rawPath
|
|
27
|
+
: path.resolve(rootDir, rawPath);
|
|
28
|
+
const projectPath = normalizeProjectRelativePath(rootDir, resolvedPath);
|
|
29
|
+
if (!projectPath || isSensitiveProjectPath(projectPath))
|
|
30
|
+
return null;
|
|
31
|
+
return projectPath.split(path.sep).join('/');
|
|
32
|
+
}
|
|
33
|
+
function renderPreview(filePath, preview) {
|
|
34
|
+
if (isTuiMode())
|
|
35
|
+
return;
|
|
36
|
+
console.log(chalk.bold(`\n Document text replacement preview for ${filePath}:`));
|
|
37
|
+
for (const line of preview.split('\n')) {
|
|
38
|
+
if (line.startsWith('+')) {
|
|
39
|
+
console.log(chalk.green(line));
|
|
40
|
+
}
|
|
41
|
+
else if (line.startsWith('-')) {
|
|
42
|
+
console.log(chalk.red(line));
|
|
43
|
+
}
|
|
44
|
+
else if (line.startsWith('@@')) {
|
|
45
|
+
console.log(chalk.cyan(line));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log(chalk.dim(line));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
console.log();
|
|
52
|
+
}
|
|
53
|
+
async function replaceDocumentTextOnServer(config, fileName, fileData, replacements, replaceAll) {
|
|
54
|
+
const response = await globalThis.fetch(`${config.serverUrl.replace(/\/+$/, '')}/v1/document/replace-text`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
authorization: `Bearer ${config.token}`,
|
|
58
|
+
'content-type': 'application/json',
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
fileName,
|
|
62
|
+
fileData: fileData.toString('base64'),
|
|
63
|
+
replacements,
|
|
64
|
+
replaceAll,
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
const data = await response.json().catch(() => null);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `Server document edit failed: ${data?.error?.message ?? response.status}`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return data;
|
|
75
|
+
}
|
|
76
|
+
export async function replaceDocumentText(context, args) {
|
|
77
|
+
const sourceRaw = String(args.filePath ?? args.file_path ?? '').trim();
|
|
78
|
+
if (!sourceRaw) {
|
|
79
|
+
return { ok: false, error: 'filePath is required' };
|
|
80
|
+
}
|
|
81
|
+
const sourcePath = relativeEditablePath(context.rootDir, sourceRaw);
|
|
82
|
+
if (!sourcePath) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: 'replace_document_text can only edit permitted files inside the project root.',
|
|
86
|
+
failureCategory: 'permission_denied',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
if (path.extname(sourcePath).toLowerCase() !== '.docx') {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
error: 'replace_document_text only supports .docx files.',
|
|
93
|
+
failureCategory: 'invalid_argument',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
|
|
97
|
+
const targetPath = outputRaw
|
|
98
|
+
? relativeEditablePath(context.rootDir, outputRaw)
|
|
99
|
+
: sourcePath;
|
|
100
|
+
if (!targetPath) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
error: 'outputPath must be inside the project root.',
|
|
104
|
+
failureCategory: 'permission_denied',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (path.extname(targetPath).toLowerCase() !== '.docx') {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: 'outputPath must end with .docx.',
|
|
111
|
+
failureCategory: 'invalid_argument',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
const replacements = normalizeReplacements(args.replacements);
|
|
115
|
+
if (!replacements.length) {
|
|
116
|
+
return {
|
|
117
|
+
ok: false,
|
|
118
|
+
error: 'replacements must include at least one { oldText, newText } item.',
|
|
119
|
+
failureCategory: 'missing_required_argument',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const authConfig = readCliAuthConfig(context.env);
|
|
123
|
+
if (!authConfig) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: 'Document editing requires a server connection. Please log in first.',
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const sourceAbsPath = resolveProjectPath(context.rootDir, sourcePath);
|
|
130
|
+
if (!existsSync(sourceAbsPath)) {
|
|
131
|
+
return {
|
|
132
|
+
ok: false,
|
|
133
|
+
filePath: sourcePath,
|
|
134
|
+
error: `File does not exist: ${sourcePath}`,
|
|
135
|
+
failureCategory: 'not_found',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const serverResult = await replaceDocumentTextOnServer(authConfig, path.basename(sourcePath), readFileSync(sourceAbsPath), replacements, args.replaceAll === true || args.replace_all === true);
|
|
139
|
+
if (!serverResult.ok) {
|
|
140
|
+
return {
|
|
141
|
+
...serverResult,
|
|
142
|
+
filePath: sourcePath,
|
|
143
|
+
failureCategory: serverResult.failureCategory ?? 'external_service',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const preview = String(serverResult.preview ?? '');
|
|
147
|
+
renderPreview(targetPath, preview);
|
|
148
|
+
if (!context.autoYes && context.confirmPatch) {
|
|
149
|
+
const confirmed = await context.confirmPatch(targetPath, preview);
|
|
150
|
+
if (!confirmed) {
|
|
151
|
+
if (!isTuiMode()) {
|
|
152
|
+
console.log(chalk.dim(` replace_document_text skipped: ${targetPath}`));
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
skipped: true,
|
|
157
|
+
filePath: targetPath,
|
|
158
|
+
error: 'User declined replace_document_text',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const fileData = String(serverResult.fileData ?? '');
|
|
163
|
+
const nextData = Buffer.from(fileData, 'base64');
|
|
164
|
+
const write = writeProjectFileBuffer(context.rootDir, targetPath, nextData);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
filePath: targetPath,
|
|
168
|
+
sourceFilePath: sourcePath,
|
|
169
|
+
changed: write.changed,
|
|
170
|
+
operation: 'replace_document_text',
|
|
171
|
+
replacementCount: serverResult.replacementCount,
|
|
172
|
+
replacements: serverResult.replacements,
|
|
173
|
+
bytesWritten: nextData.length,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { readProjectFile, writeProjectFile } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
function countOccurrences(haystack, needle) {
|
|
9
11
|
if (needle.length === 0)
|
|
10
12
|
return 0;
|
|
@@ -92,6 +94,17 @@ export async function strReplace(context, args) {
|
|
|
92
94
|
if (oldString.length === 0) {
|
|
93
95
|
return { ok: false, error: 'old_string is required and must be non-empty' };
|
|
94
96
|
}
|
|
97
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
98
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
filePath,
|
|
102
|
+
error: ext === '.docx'
|
|
103
|
+
? 'Use replace_document_text for .docx files.'
|
|
104
|
+
: `Use read_document for ${ext} files; text replacement is not supported.`,
|
|
105
|
+
failureCategory: 'invalid_argument',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
95
108
|
let originalContent;
|
|
96
109
|
try {
|
|
97
110
|
originalContent = readProjectFile(rootDir, filePath);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import {
|
|
3
|
-
import { deleteProjectFile, writeProjectFile, } from '../patcher.js';
|
|
2
|
+
import { hashStoredContent, readFileEditSnapshot, storedContentBuffer, } from '../edit-journal.js';
|
|
3
|
+
import { deleteProjectFile, writeProjectFile, writeProjectFileBuffer, } from '../patcher.js';
|
|
4
4
|
import { removeIndexFile, upsertIndexFile, } from '../project-index.js';
|
|
5
5
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
6
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
@@ -84,7 +84,7 @@ function validateUndoPlan(rootDir, records) {
|
|
|
84
84
|
currentHash,
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
const beforeContentHash =
|
|
87
|
+
const beforeContentHash = hashStoredContent(record.beforeContent, record.beforeContentEncoding);
|
|
88
88
|
if (beforeContentHash !== record.beforeHash) {
|
|
89
89
|
return {
|
|
90
90
|
ok: false,
|
|
@@ -110,7 +110,9 @@ async function applyUndo(context, record) {
|
|
|
110
110
|
if (record.beforeContent === null) {
|
|
111
111
|
throw new Error(`Cannot undo ${record.id}: the stored pre-edit snapshot is incomplete.`);
|
|
112
112
|
}
|
|
113
|
-
const { changed } =
|
|
113
|
+
const { changed } = record.beforeContentEncoding === 'base64'
|
|
114
|
+
? writeProjectFileBuffer(rootDir, record.filePath, storedContentBuffer(record.beforeContent, record.beforeContentEncoding))
|
|
115
|
+
: writeProjectFile(rootDir, record.filePath, record.beforeContent);
|
|
114
116
|
if (changed) {
|
|
115
117
|
await upsertIndexFile(projectIndex, record.filePath);
|
|
116
118
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { normalizeProjectRelativePath } from '../artifact-policy.js';
|
|
3
4
|
import { writeProjectFile } from '../patcher.js';
|
|
4
5
|
import { upsertIndexFile } from '../project-index.js';
|
|
5
6
|
import { isTuiMode } from '../runtime-mode.js';
|
|
6
7
|
import { getCurrentFileHash, hasFreshFullReadCoverage, resolveRedactionTokens, } from '../session-safety.js';
|
|
7
8
|
import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
|
|
9
|
+
const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
|
|
8
10
|
export async function writeFile(context, args) {
|
|
9
11
|
const { rootDir, projectIndex } = context;
|
|
10
12
|
const filePath = String(args.filePath ?? '').trim();
|
|
@@ -12,6 +14,17 @@ export async function writeFile(context, args) {
|
|
|
12
14
|
if (!filePath) {
|
|
13
15
|
return { ok: false, error: 'filePath is required' };
|
|
14
16
|
}
|
|
17
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
18
|
+
if (DOCUMENT_EXTENSIONS.has(ext)) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
filePath,
|
|
22
|
+
error: ext === '.docx'
|
|
23
|
+
? 'Use replace_document_text for .docx files.'
|
|
24
|
+
: `Use read_document for ${ext} files; write_file is not supported.`,
|
|
25
|
+
failureCategory: 'invalid_argument',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
15
28
|
const coveragePath = normalizeProjectRelativePath(rootDir, filePath) ?? filePath;
|
|
16
29
|
const currentHash = getCurrentFileHash(rootDir, filePath);
|
|
17
30
|
if (currentHash !== null &&
|
package/dist/src/ui/repl.js
CHANGED
|
@@ -11,7 +11,7 @@ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSes
|
|
|
11
11
|
import { truncate } from '../utils.js';
|
|
12
12
|
import { writeClipboardText } from '../core/clipboard.js';
|
|
13
13
|
import { openUrl } from '../core/open-url.js';
|
|
14
|
-
import { formatInteractiveHelpText } from '../help-text.js';
|
|
14
|
+
import { formatAboutCard, formatInteractiveHelpText } from '../help-text.js';
|
|
15
15
|
import { expandPastedChunks, } from './paste-collapse.js';
|
|
16
16
|
import { loadPromptHistory, MAX_PROMPT_HISTORY_ENTRIES, } from './prompt-history-store.js';
|
|
17
17
|
const RESPONSE_STREAM_CHUNK_SIZE = 4;
|
|
@@ -86,6 +86,10 @@ export const SLASH_COMMANDS = [
|
|
|
86
86
|
command: '/help',
|
|
87
87
|
description: 'Show available chat commands',
|
|
88
88
|
},
|
|
89
|
+
{
|
|
90
|
+
command: '/about',
|
|
91
|
+
description: 'Show version and platform info',
|
|
92
|
+
},
|
|
89
93
|
{
|
|
90
94
|
command: '/usage',
|
|
91
95
|
description: 'Show account usage percentage and reset times',
|
|
@@ -1875,6 +1879,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1875
1879
|
});
|
|
1876
1880
|
return;
|
|
1877
1881
|
}
|
|
1882
|
+
if (input === '/about') {
|
|
1883
|
+
appendStaticEntry({
|
|
1884
|
+
body: formatAboutCard(),
|
|
1885
|
+
kind: 'system',
|
|
1886
|
+
title: 'About',
|
|
1887
|
+
});
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1878
1890
|
if (input === '/usage') {
|
|
1879
1891
|
store.update((current) => ({
|
|
1880
1892
|
...current,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const PACKAGE_NAME = '@thegitai/cli';
|
|
5
|
+
const UNKNOWN_VERSION = '0.0.0';
|
|
6
|
+
// Resolve the package version at runtime by walking up from this module to the
|
|
7
|
+
// nearest package.json named @thegitai/cli. This works in both layouts: the
|
|
8
|
+
// compiled binary (dist/bin/ai.js → ../../package.json) and the source tree run
|
|
9
|
+
// under tsx in tests (src/version.ts → ../package.json). The name guard avoids
|
|
10
|
+
// picking up an unrelated manifest if the file is ever nested elsewhere.
|
|
11
|
+
export function getCliVersion() {
|
|
12
|
+
let dir = path.dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
for (let depth = 0; depth < 6; depth++) {
|
|
14
|
+
try {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
16
|
+
if (pkg.name === PACKAGE_NAME && typeof pkg.version === 'string') {
|
|
17
|
+
return pkg.version;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// No package.json at this level (or unreadable); keep walking up.
|
|
22
|
+
}
|
|
23
|
+
const parent = path.dirname(dir);
|
|
24
|
+
if (parent === dir)
|
|
25
|
+
break;
|
|
26
|
+
dir = parent;
|
|
27
|
+
}
|
|
28
|
+
return UNKNOWN_VERSION;
|
|
29
|
+
}
|
|
30
|
+
export function getPlatformTag() {
|
|
31
|
+
return `${process.platform}-${process.arch}`;
|
|
32
|
+
}
|
|
33
|
+
export function formatVersionLine() {
|
|
34
|
+
return `ai ${getCliVersion()} (${getPlatformTag()}, node ${process.version})`;
|
|
35
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.7",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"web-tree-sitter": "^0.26.6"
|
|
28
28
|
},
|
|
29
29
|
"optionalDependencies": {
|
|
30
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
32
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
33
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
30
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.7",
|
|
31
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.7",
|
|
32
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.7",
|
|
33
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.7"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|