@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 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;
@@ -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,
@@ -37,6 +37,15 @@ export const BINARY_ARTIFACT_EXTENSIONS = createArtifactNameSet([
37
37
  '.bz2',
38
38
  '.7z',
39
39
  '.pdf',
40
+ '.doc',
41
+ '.docx',
42
+ '.docm',
43
+ '.dot',
44
+ '.dotx',
45
+ '.dotm',
46
+ '.xls',
47
+ '.xlsx',
48
+ '.xlsm',
40
49
  '.exe',
41
50
  '.dll',
42
51
  '.so',
@@ -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
+ }
@@ -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 = 1_000_000;
7
- export function hashContent(content) {
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, 'utf-8');
25
- return { exists: true, content, hash: hashContent(content) };
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(),
@@ -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
  }
@@ -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)) {
@@ -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, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
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 { hashContent, readFileEditSnapshot } from './edit-journal.js';
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 = 1_000_000;
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 ?? ''}`, file.content);
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 ? file : { ...file, content };
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) => [edit.id, edit.beforeContent]));
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 ? edit : { ...edit, beforeContent };
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 = writeProjectFile(args.rootDir, snapshot.filePath, snapshot.content ?? '');
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
- writeProjectFile(args.rootDir, item.snapshot.filePath, item.before.content);
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 = git(['show', `HEAD:${relToGit}`], gitRoot);
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: hashContent(content),
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);
@@ -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 .xlsx.`,
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 { hashContent, readFileEditSnapshot, } from '../edit-journal.js';
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 = hashContent(record.beforeContent);
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 } = writeProjectFile(rootDir, record.filePath, record.beforeContent);
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 &&
@@ -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.5",
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.5",
31
- "@thegitai/tui-darwin-x64": "1.0.0-beta.5",
32
- "@thegitai/tui-linux-x64": "1.0.0-beta.5",
33
- "@thegitai/tui-win32-x64": "1.0.0-beta.5"
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"