@thegitai/cli 1.0.0-beta.1 → 1.0.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +4 -3
  2. package/dist/bin/ai.js +18 -61
  3. package/dist/parsers/NOTICE +18 -0
  4. package/dist/src/api/auth.js +4 -2
  5. package/dist/src/api/browser-login.js +10 -28
  6. package/dist/src/api/chat.js +20 -9
  7. package/dist/src/api/http.js +32 -3
  8. package/dist/src/api/models.js +4 -2
  9. package/dist/src/artifact-policy.js +9 -0
  10. package/dist/src/cli-args.js +65 -0
  11. package/dist/src/client-environment.js +127 -0
  12. package/dist/src/colors.js +59 -0
  13. package/dist/src/core/clipboard.js +56 -0
  14. package/dist/src/edit-journal.js +39 -6
  15. package/dist/src/executor.js +28 -5
  16. package/dist/src/help-text.js +15 -1
  17. package/dist/src/markdown-renderer.js +1 -1
  18. package/dist/src/patcher.js +18 -1
  19. package/dist/src/scanner.js +67 -17
  20. package/dist/src/session-safety.js +64 -12
  21. package/dist/src/tool-executor.js +8 -0
  22. package/dist/src/tools/delete-file.js +1 -1
  23. package/dist/src/tools/index.js +2 -0
  24. package/dist/src/tools/patch-file.js +14 -1
  25. package/dist/src/tools/path-suggest.js +66 -0
  26. package/dist/src/tools/read-document.js +14 -3
  27. package/dist/src/tools/read-file.js +9 -0
  28. package/dist/src/tools/replace-document-text.js +242 -0
  29. package/dist/src/tools/restore-checkpoint.js +1 -1
  30. package/dist/src/tools/run-command.js +1 -1
  31. package/dist/src/tools/run-node-script.js +1 -1
  32. package/dist/src/tools/str-replace.js +14 -1
  33. package/dist/src/tools/undo-edit.js +7 -5
  34. package/dist/src/tools/write-file.js +14 -1
  35. package/dist/src/tree-sitter-runtime.js +14 -1
  36. package/dist/src/ui/repl.js +13 -1
  37. package/dist/src/ui/tui/bridge.js +2 -2
  38. package/dist/src/ui/tui/build-frame.js +18 -2
  39. package/dist/src/ui/tui/shell-input.js +9 -1
  40. package/dist/src/version.js +35 -0
  41. package/dist/vendor/web-tree-sitter/LICENSE +21 -0
  42. package/dist/vendor/web-tree-sitter/NOTICE +13 -0
  43. package/dist/vendor/web-tree-sitter/web-tree-sitter.cjs +4063 -0
  44. package/dist/vendor/web-tree-sitter/web-tree-sitter.wasm +0 -0
  45. package/package.json +15 -16
@@ -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
+ }
@@ -0,0 +1,59 @@
1
+ // Dependency-free ANSI styler with a chalk-compatible surface, imported as
2
+ // `chalk` at call sites. Color gating (NO_COLOR / FORCE_COLOR / non-TTY) lives
3
+ // in colorEnabled() below.
4
+ const STYLE_NAMES = ['bold', 'dim', 'red', 'green', 'yellow', 'cyan'];
5
+ // SGR open/close codes. Bold and dim share the 22 reset; colors share 39, so a
6
+ // nested inner style restores exactly its own attribute without clearing the
7
+ // outer one.
8
+ const OPEN = {
9
+ bold: '\x1b[1m',
10
+ dim: '\x1b[2m',
11
+ red: '\x1b[31m',
12
+ green: '\x1b[32m',
13
+ yellow: '\x1b[33m',
14
+ cyan: '\x1b[36m',
15
+ };
16
+ const CLOSE = {
17
+ bold: '\x1b[22m',
18
+ dim: '\x1b[22m',
19
+ red: '\x1b[39m',
20
+ green: '\x1b[39m',
21
+ yellow: '\x1b[39m',
22
+ cyan: '\x1b[39m',
23
+ };
24
+ function colorEnabled() {
25
+ const force = process.env.FORCE_COLOR;
26
+ if (force !== undefined)
27
+ return force !== '0' && force !== 'false';
28
+ if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
29
+ return false;
30
+ }
31
+ return Boolean(process.stdout.isTTY);
32
+ }
33
+ function applyStyle(name, text) {
34
+ const open = OPEN[name];
35
+ const close = CLOSE[name];
36
+ // Re-open this style after any inner close of the same code, so a nested
37
+ // style (e.g. chalk.red(`a ${chalk.bold('b')} c`)) doesn't terminate it early.
38
+ const body = text.includes(close) ? text.split(close).join(close + open) : text;
39
+ return open + body + close;
40
+ }
41
+ function createStyler(styles) {
42
+ const fn = ((text) => {
43
+ const value = String(text);
44
+ if (!colorEnabled() || styles.length === 0)
45
+ return value;
46
+ // Apply right-to-left so the first style in the chain is outermost.
47
+ return styles.reduceRight((acc, name) => applyStyle(name, acc), value);
48
+ });
49
+ for (const name of STYLE_NAMES) {
50
+ Object.defineProperty(fn, name, {
51
+ configurable: true,
52
+ enumerable: false,
53
+ get: () => createStyler([...styles, name]),
54
+ });
55
+ }
56
+ return fn;
57
+ }
58
+ const colors = createStyler([]);
59
+ export default colors;
@@ -21,6 +21,14 @@ export function isSupportedImageMimeType(mime) {
21
21
  }
22
22
  function whichSync(cmd) {
23
23
  try {
24
+ if (process.platform === 'win32') {
25
+ execFileSync('where.exe', [cmd], {
26
+ stdio: 'ignore',
27
+ timeout: 2000,
28
+ windowsHide: true,
29
+ });
30
+ return true;
31
+ }
24
32
  execFileSync('which', [cmd], { stdio: 'ignore', timeout: 2000 });
25
33
  return true;
26
34
  }
@@ -93,6 +101,52 @@ function readClipboardLinux() {
93
101
  }
94
102
  throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
95
103
  }
104
+ const WINDOWS_CLIPBOARD_IMAGE_PS = [
105
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8;',
106
+ '$ErrorActionPreference = "Stop";',
107
+ 'Add-Type -AssemblyName System.Drawing;',
108
+ '$img = Get-Clipboard -Format Image;',
109
+ 'if ($null -eq $img) { exit 2 }',
110
+ '$ms = New-Object System.IO.MemoryStream;',
111
+ '$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);',
112
+ '[Convert]::ToBase64String($ms.ToArray())',
113
+ ].join(' ');
114
+ function readClipboardWindows() {
115
+ try {
116
+ const b64 = execFileSync('powershell.exe', ['-NoProfile', '-Command', WINDOWS_CLIPBOARD_IMAGE_PS], {
117
+ encoding: 'utf-8',
118
+ timeout: 5000,
119
+ maxBuffer: MAX_IMAGE_SIZE_BYTES * 2,
120
+ stdio: ['ignore', 'pipe', 'pipe'],
121
+ windowsHide: true,
122
+ }).trim();
123
+ if (!b64) {
124
+ throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
125
+ }
126
+ const buf = Buffer.from(b64, 'base64');
127
+ if (!buf.length) {
128
+ throw new ClipboardError('Clipboard contains no image data.', 'NO_IMAGE');
129
+ }
130
+ if (buf.length > MAX_IMAGE_SIZE_BYTES) {
131
+ throw new ClipboardError('Clipboard image exceeds 10MB size limit.', 'READ_FAILED');
132
+ }
133
+ return { base64Data: b64, mimeType: 'image/png' };
134
+ }
135
+ catch (err) {
136
+ if (err instanceof ClipboardError)
137
+ throw err;
138
+ if (err?.status === 2) {
139
+ throw new ClipboardError('Clipboard contains no image data. Copy an image first (Win+Shift+S), then press Alt+V.', 'NO_IMAGE');
140
+ }
141
+ if (isMaxBufferError(err)) {
142
+ throw new ClipboardError('Clipboard image exceeds 10MB size limit.', 'READ_FAILED');
143
+ }
144
+ const detail = [err?.message, err?.stderr?.toString?.()?.trim()]
145
+ .filter(Boolean)
146
+ .join(' — ');
147
+ throw new ClipboardError(`Failed to read clipboard image on Windows: ${detail || 'unknown error'}`, 'READ_FAILED');
148
+ }
149
+ }
96
150
  function readClipboardDarwin() {
97
151
  if (whichSync('pngpaste')) {
98
152
  try {
@@ -123,6 +177,8 @@ export function readClipboardImage(platform = process.platform) {
123
177
  return readClipboardLinux();
124
178
  case 'darwin':
125
179
  return readClipboardDarwin();
180
+ case 'win32':
181
+ return readClipboardWindows();
126
182
  default:
127
183
  throw new ClipboardError(`Clipboard image paste is not supported on ${platform}.`, 'NO_TOOL');
128
184
  }
@@ -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,11 +1,28 @@
1
- import chalk from 'chalk';
2
- import * as pty from '@homebridge/node-pty-prebuilt-multiarch';
1
+ import chalk from './colors.js';
3
2
  import { execFileSync, spawn } from 'child_process';
4
3
  import { existsSync, statSync } from 'fs';
4
+ import { createRequire } from 'node:module';
5
5
  import os from 'os';
6
6
  import path from 'path';
7
7
  import { ARTIFACT_INSPECT_BLOCK_DIRS, getBlockedArtifactInspectDir, relativeProjectPath, } from './artifact-policy.js';
8
8
  import { emitCommandOutput, isTuiMode } from './runtime-mode.js';
9
+ const requireFromHere = createRequire(import.meta.url);
10
+ let nodePtyCache;
11
+ // @lydell/node-pty ships its native binding as platform-specific optional
12
+ // packages. If none is installed for this OS/arch the require throws; we cache
13
+ // the failure and fall back to the non-interactive child_process path. A pty is
14
+ // only needed to answer interactive sudo prompts.
15
+ function loadNodePty() {
16
+ if (nodePtyCache !== undefined)
17
+ return nodePtyCache;
18
+ try {
19
+ nodePtyCache = requireFromHere('@lydell/node-pty');
20
+ }
21
+ catch {
22
+ nodePtyCache = null;
23
+ }
24
+ return nodePtyCache;
25
+ }
9
26
  const COMMON_TOOLCHAIN_BIN_DIRS = ['/usr/local/go/bin'];
10
27
  function detectVenvBin(dir) {
11
28
  for (const name of ['.venv', 'venv', 'env']) {
@@ -607,7 +624,7 @@ function buildCommandEnv(cwd) {
607
624
  function sanitizePtyOutput(command, output, cwd, secrets) {
608
625
  return sanitizeCommandText(command, stripSudoPromptText(redactSecrets(output, secrets)), cwd);
609
626
  }
610
- async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword) {
627
+ async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty) {
611
628
  return new Promise((resolve) => {
612
629
  let output = '';
613
630
  let timedOut = false;
@@ -623,7 +640,7 @@ async function runPtyCommand(command, cwd, effectiveTimeout, exploratory, reques
623
640
  const args = process.platform === 'win32'
624
641
  ? ['/d', '/s', '/c', command]
625
642
  : ['-lc', command];
626
- const child = pty.spawn(shell, args, {
643
+ const child = nodePty.spawn(shell, args, {
627
644
  cols: 120,
628
645
  rows: 30,
629
646
  cwd,
@@ -776,7 +793,13 @@ export async function runCommand(command, cwd, { requestSudoPassword, timeout, }
776
793
  }
777
794
  const exploratory = isExploratoryCommand(command);
778
795
  if (requestSudoPassword && commandUsesSudo(command)) {
779
- return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword);
796
+ const nodePty = loadNodePty();
797
+ if (nodePty) {
798
+ return runPtyCommand(command, cwd, effectiveTimeout, exploratory, requestSudoPassword, nodePty);
799
+ }
800
+ // No pty binding installed for this platform — fall through to the
801
+ // non-interactive spawn path. The command still runs; an interactive sudo
802
+ // prompt simply can't be answered here.
780
803
  }
781
804
  return new Promise((resolve) => {
782
805
  let stdout = '';
@@ -1,4 +1,5 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
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
  }
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
2
2
  function renderInline(text) {
3
3
  const parts = String(text ?? '').split(/(`[^`]+`)/g);
4
4
  return parts
@@ -1,4 +1,4 @@
1
- import chalk from 'chalk';
1
+ import chalk from './colors.js';
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
3
3
  import path from 'path';
4
4
  import { createInterface } from 'readline';
@@ -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)) {
@@ -1,14 +1,12 @@
1
1
  import { execSync } from 'child_process';
2
- import { readFileSync, statSync } from 'fs';
3
- import { glob } from 'glob';
2
+ import { readdirSync, readFileSync, statSync } from 'fs';
4
3
  import path from 'path';
5
4
  import { getNodePrimarySignature, getStructuralChildren, parseRepoSource, } from './tree-sitter-runtime.js';
6
- import { ARTIFACT_FALLBACK_IGNORE_GLOBS, ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
5
+ import { ARTIFACT_IGNORE_DIRS, ARTIFACT_IGNORE_FILES, ARTIFACT_INSPECT_BLOCK_DIRS, BINARY_ARTIFACT_EXTENSIONS, isSensitiveProjectPath, shouldIgnoreArtifactPath, } from './artifact-policy.js';
7
6
  const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
8
7
  const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
9
8
  export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
10
9
  export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
11
- const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
12
10
  export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
13
11
  const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
14
12
  const MAX_CHUNKS = 2000;
@@ -16,26 +14,78 @@ const TARGET_CHUNK_CHARS = 1800;
16
14
  const MAX_CHUNK_CHARS = 2800;
17
15
  const FALLBACK_OVERLAP_LINES = 10;
18
16
  const MAX_STRUCTURE_DEPTH = 2;
17
+ function parseGitLsFilesOutput(output) {
18
+ return String(output)
19
+ .split('\0')
20
+ .filter(Boolean)
21
+ .filter((filePath) => !shouldIgnorePath(filePath));
22
+ }
19
23
  function getFiles(rootDir, { limit = Infinity } = {}) {
20
24
  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));
25
+ const output = execSync('git ls-files -z --cached --others --exclude-standard', { cwd: rootDir, stdio: ['pipe', 'pipe', 'pipe'] });
26
+ const files = parseGitLsFilesOutput(output);
26
27
  return Number.isFinite(limit) ? files.slice(0, limit) : files;
27
28
  }
28
29
  catch {
29
- return glob
30
- .sync('**/*', {
31
- cwd: rootDir,
32
- nodir: true,
33
- dot: false,
34
- ignore: FALLBACK_IGNORE,
35
- })
36
- .slice(0, Number.isFinite(limit) ? limit : undefined);
30
+ return walkProjectFilesFallback(rootDir, Number.isFinite(limit) ? limit : Infinity);
37
31
  }
38
32
  }
33
+ const FALLBACK_LOCKFILES = new Set([
34
+ 'package-lock.json',
35
+ 'yarn.lock',
36
+ 'pnpm-lock.yaml',
37
+ ]);
38
+ function isFallbackIgnoredFile(relPath, fileName) {
39
+ if (shouldIgnorePath(relPath))
40
+ return true;
41
+ if (fileName.endsWith('.lock'))
42
+ return true;
43
+ return FALLBACK_LOCKFILES.has(fileName);
44
+ }
45
+ // Local stand-in for the previous glob('**/*', { nodir: true, dot: false,
46
+ // ignore: FALLBACK_IGNORE }) call, used only when `git ls-files` fails (e.g. a
47
+ // non-git directory). Walks the tree depth-first, skips dotfiles and
48
+ // dot-directories (glob's dot: false), prunes ignored artifact dirs, and drops
49
+ // lockfiles plus sensitive/ignored paths — reproducing the old fallback without
50
+ // the glob dependency.
51
+ function walkProjectFilesFallback(rootDir, limit) {
52
+ const results = [];
53
+ const visit = (relDir) => {
54
+ if (results.length >= limit)
55
+ return;
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(path.join(rootDir, relDir), {
59
+ withFileTypes: true,
60
+ });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ if (results.length >= limit)
67
+ return;
68
+ const name = entry.name;
69
+ if (name.startsWith('.'))
70
+ continue;
71
+ const relPath = relDir ? `${relDir}/${name}` : name;
72
+ if (entry.isDirectory()) {
73
+ // Only the prefix-based artifact rule is safe to prune a directory by;
74
+ // the sensitive-basename check must stay at the file level so a dir
75
+ // merely named e.g. `secret` doesn't hide non-sensitive files under it.
76
+ if (ALWAYS_IGNORE_DIRS.has(name) || shouldIgnoreArtifactPath(relPath)) {
77
+ continue;
78
+ }
79
+ visit(relPath);
80
+ }
81
+ else if (entry.isFile() && !isFallbackIgnoredFile(relPath, name)) {
82
+ results.push(relPath);
83
+ }
84
+ }
85
+ };
86
+ visit('');
87
+ return results;
88
+ }
39
89
  function shouldSkipFile(relPath, stat) {
40
90
  if (shouldIgnorePath(relPath))
41
91
  return true;