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

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.
@@ -148,12 +148,18 @@ export const ARTIFACT_FALLBACK_IGNORE_GLOBS = [
148
148
  '**/pnpm-lock.yaml',
149
149
  ...ARTIFACT_IGNORE_PATH_PREFIXES.map((prefix) => `${prefix}/**`),
150
150
  ];
151
+ // Kept in lockstep with the server's secret-path check: the client repair guard
152
+ // and the server-side secret check must agree on what counts as a secret, or a
153
+ // quoted/curly secret path the client repairs (e.g. service-account.json) slips
154
+ // past the server check, which keys off the original tool-call args.
151
155
  const SENSITIVE_BASENAME_PATTERNS = [
152
156
  /^\.env(?:\..+)?$/i,
153
157
  /^\.?npmrc$/i,
154
158
  /^\.?pypirc$/i,
155
159
  /^credentials(?:\..*)?$/i,
156
160
  /^secrets?(?:\..*)?$/i,
161
+ /^service[-_]?account(?:\..*)?\.json$/i,
162
+ /^.*credentials.*\.json$/i,
157
163
  ];
158
164
  const SENSITIVE_PATH_PATTERNS = [
159
165
  /(^|[/\\])\.aws[/\\]credentials$/i,
@@ -161,6 +167,7 @@ const SENSITIVE_PATH_PATTERNS = [
161
167
  /(^|[/\\])credentials?([._-]|$)/i,
162
168
  /(^|[/\\])secrets?([._-]|$)/i,
163
169
  /(^|[/\\])private[-_]?key([._-]|$)/i,
170
+ /(^|[/\\])service[-_]?account/i,
164
171
  /\.(?:pem|key|p12|pfx)$/i,
165
172
  ];
166
173
  export function normalizeArtifactPath(relPath) {
@@ -2,6 +2,7 @@ import { canStoreEditSnapshot, isEditToolName, isGitWorkTree, MAX_EDIT_JOURNAL_R
2
2
  import { clearEditFailure, collectCommandMutations, captureMutationBaseline, ensureActiveCheckpoint, recordEditFailure, recordSessionEdit, rememberCheckpointFiles, } from './session-safety.js';
3
3
  import { buildAgentModeToolBlockedResult, } from './agent-mode.js';
4
4
  import { dispatchTool } from './tools/index.js';
5
+ import { PATH_REPAIRING_EDIT_TOOLS, repairFilePath } from './tools/path-suggest.js';
5
6
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './tools/shell-diagnostics.js';
6
7
  const EDIT_FILE_PATH_ARG_ALIASES = [
7
8
  'filePath',
@@ -45,6 +46,17 @@ function getEditToolFilePath(call) {
45
46
  }
46
47
  return '';
47
48
  }
49
+ // replace_document_text repairs its source filePath but writes a separate
50
+ // outputPath verbatim (a write target must not be fold-matched onto a different
51
+ // existing file). When an outputPath is given, the snapshot path it returns is
52
+ // that raw output, so it must not be repaired.
53
+ function editToolWritesSeparateOutput(call) {
54
+ if (call.name !== 'replace_document_text')
55
+ return false;
56
+ const args = call.args && typeof call.args === 'object' ? call.args : {};
57
+ const output = args.outputPath ?? args.output_path;
58
+ return typeof output === 'string' && output.trim().length > 0;
59
+ }
48
60
  function recordAssistantEdit(session, call, result, before) {
49
61
  if (!before || !isEditToolName(call.name))
50
62
  return;
@@ -119,7 +131,21 @@ export async function executeLocalToolCall(toolContext, session, call) {
119
131
  session.onToolEvent?.({ call, result });
120
132
  return result;
121
133
  }
122
- const filePathBeforeEdit = isEditToolName(call.name) ? getEditToolFilePath(call) : '';
134
+ // Snapshot the real file the edit tool will touch. Tools that repair their
135
+ // path internally (str_replace/patch_file/replace_document_text) must be
136
+ // snapshotted against the repaired path, or the pre-edit snapshot targets
137
+ // the unrepaired path and the edit is journaled as a `create` and undone by
138
+ // deleting the user's file. write_file/delete_file consume the raw path, so
139
+ // repairing their snapshot would instead journal a phantom edit of a
140
+ // different file — keep them on the raw path.
141
+ const rawEditFilePath = isEditToolName(call.name)
142
+ ? getEditToolFilePath(call)
143
+ : '';
144
+ const filePathBeforeEdit = rawEditFilePath &&
145
+ PATH_REPAIRING_EDIT_TOOLS.has(call.name) &&
146
+ !editToolWritesSeparateOutput(call)
147
+ ? repairFilePath(session.rootDir, rawEditFilePath)
148
+ : rawEditFilePath;
123
149
  if (filePathBeforeEdit) {
124
150
  rememberCheckpointFiles(session.clientState.safety, session.rootDir, [filePathBeforeEdit], session.turnState.id);
125
151
  }
@@ -3,13 +3,14 @@ import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { applyUnifiedPatch, readProjectFile, renderDiffPreview, writeProjectFile, } from '../patcher.js';
5
5
  import { upsertIndexFile } from '../project-index.js';
6
+ import { repairFilePath } from './path-suggest.js';
6
7
  import { isTuiMode } from '../runtime-mode.js';
7
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
8
9
  import { invalidateShellDiagnosticsCache, runShellDiagnostics, } from './shell-diagnostics.js';
9
10
  const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
10
11
  export async function patchFile(context, args) {
11
12
  const { rootDir, projectIndex, autoYes, confirmPatch } = context;
12
- const filePath = String(args.filePath ?? '').trim();
13
+ const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
13
14
  let patch = typeof args.patch === 'string' ? args.patch : '';
14
15
  if (!filePath) {
15
16
  return { ok: false, error: 'filePath is required' };
@@ -1,6 +1,6 @@
1
- import { readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { isSensitiveProjectPath } from '../artifact-policy.js';
3
+ import { isSensitiveProjectPath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
4
4
  // "File not found" recovery hint: when a model mistypes a filename (most
5
5
  // often Unicode punctuation — a straight ' for a curly ’ — or a small typo),
6
6
  // suggest the closest real file from the same directory.
@@ -64,3 +64,113 @@ export function suggestClosestPath(rootDir, missingPath) {
64
64
  const relative = path.relative(rootDir, suggested);
65
65
  return relative && !relative.startsWith('..') ? relative : suggested;
66
66
  }
67
+ // Fold only the punctuation/whitespace a model routinely alters when it echoes
68
+ // a filename — a curly apostrophe ’ flattened to a straight ', smart double
69
+ // quotes, and a non-breaking space — WITHOUT touching case, so a path is only
70
+ // auto-corrected when nothing but this punctuation differs from a real file.
71
+ function foldPunctuation(name) {
72
+ return name
73
+ .normalize('NFC')
74
+ .replace(/[‘’ʼ]/g, "'")
75
+ .replace(/[“”]/g, '"')
76
+ .replace(/ /g, ' ');
77
+ }
78
+ // Strip ONE matched pair of surrounding quotes. A path pasted from a file
79
+ // manager's "Copy as path" or dragged into a terminal arrives wrapped in
80
+ // '…' / "…", and that wrapping is captured verbatim as part of the filename.
81
+ function stripSurroundingQuotes(p) {
82
+ if (p.length >= 2) {
83
+ const first = p[0];
84
+ const last = p[p.length - 1];
85
+ if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
86
+ return p.slice(1, -1);
87
+ }
88
+ }
89
+ return p;
90
+ }
91
+ // A single backslash is a legal filename byte on POSIX, but models routinely
92
+ // double it (it is JSON's escape character) when echoing a name, turning
93
+ // `back\slash.js` into `back\\slash.js`. Collapse doubled backslashes to one.
94
+ function collapseDoubledBackslashes(p) {
95
+ return p.replace(/\\\\/g, '\\');
96
+ }
97
+ function resolveAgainst(rootDir, p) {
98
+ return path.isAbsolute(p) ? p : path.resolve(rootDir, p);
99
+ }
100
+ function existsAgainst(rootDir, p) {
101
+ try {
102
+ return existsSync(resolveAgainst(rootDir, p));
103
+ }
104
+ catch {
105
+ return false;
106
+ }
107
+ }
108
+ // Repair must never resolve a protected file (a secret like `.env`/credentials).
109
+ // Repair only runs when the literal path is missing, so without this a quoted or
110
+ // curly-flattened secret path — which used to fail as not-found — would be
111
+ // silently resolved to the real secret, bypassing the redaction that keys off
112
+ // the original tool-call args (e.g. read_document, str_replace, patch_file).
113
+ function isProtectedRepairTarget(rootDir, candidate) {
114
+ const rel = path.relative(rootDir, resolveAgainst(rootDir, candidate));
115
+ const projectPath = rel && !rel.startsWith('..') ? rel : candidate;
116
+ return (isSensitiveProjectPath(projectPath) ||
117
+ (rel !== '' && !rel.startsWith('..') && shouldIgnoreArtifactPath(rel)));
118
+ }
119
+ // Edit tools that call repairFilePath on their path argument internally. The
120
+ // executor repairs the pre-edit snapshot path only for these, so its snapshot
121
+ // targets the same file the tool writes; write_file/delete_file consume the raw
122
+ // path, so their snapshot must too.
123
+ export const PATH_REPAIRING_EDIT_TOOLS = new Set([
124
+ 'str_replace',
125
+ 'patch_file',
126
+ 'replace_document_text',
127
+ ]);
128
+ // Repair a model/user-supplied path to a real on-disk file WITHOUT changing
129
+ // intent, for tools that act on a file expected to already exist. Literal
130
+ // first: any path that already resolves — including one that legitimately
131
+ // contains quotes or backslashes — is returned untouched. Only when the path
132
+ // does not resolve do we try safe de-manglings: strip surrounding quotes,
133
+ // collapse doubled backslashes, and finally match a directory entry that
134
+ // differs only by foldable punctuation (the "model flattened a curly ’ to a
135
+ // straight '" case, which no transform of the input can reproduce). Returns the
136
+ // input unchanged when nothing better exists, so the caller's normal not-found
137
+ // handling (and its recovery hint) still fires.
138
+ export function repairFilePath(rootDir, raw) {
139
+ if (!raw || existsAgainst(rootDir, raw))
140
+ return raw;
141
+ const dequoted = stripSurroundingQuotes(raw);
142
+ for (const candidate of [
143
+ dequoted,
144
+ collapseDoubledBackslashes(raw),
145
+ collapseDoubledBackslashes(dequoted),
146
+ ]) {
147
+ if (candidate !== raw &&
148
+ existsAgainst(rootDir, candidate) &&
149
+ !isProtectedRepairTarget(rootDir, candidate)) {
150
+ return candidate;
151
+ }
152
+ }
153
+ const probe = resolveAgainst(rootDir, dequoted);
154
+ const directory = path.dirname(probe);
155
+ const wanted = foldPunctuation(path.basename(probe));
156
+ if (!wanted)
157
+ return raw;
158
+ let entries;
159
+ try {
160
+ entries = readdirSync(directory);
161
+ }
162
+ catch {
163
+ return raw;
164
+ }
165
+ const matches = entries.filter((entry) => foldPunctuation(entry) === wanted);
166
+ if (matches.length !== 1)
167
+ return raw; // none, or ambiguous — never guess
168
+ const matchedAbs = path.join(directory, matches[0]);
169
+ const matchedRel = path.relative(rootDir, matchedAbs);
170
+ const matched = path.isAbsolute(dequoted) ? matchedAbs : matchedRel || matchedAbs;
171
+ // Never auto-resolve into a protected file: a fold-match that lands on `.env`
172
+ // would confirm its existence to the model and bypass redaction.
173
+ if (isProtectedRepairTarget(rootDir, matched))
174
+ return raw;
175
+ return matched;
176
+ }
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
3
  import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
4
- import { suggestClosestPath } from './path-suggest.js';
4
+ import { repairFilePath, suggestClosestPath } from './path-suggest.js';
5
5
  import { readCliAuthConfig } from '../api/auth.js';
6
6
  export function normalizeDocumentText(raw) {
7
7
  const text = String(raw ?? '').replace(/\r\n?/g, '\n');
@@ -71,7 +71,7 @@ async function parseDocumentOnServer(config, fileName, fileData, ext, args) {
71
71
  return data;
72
72
  }
73
73
  export async function readDocument(rootDir, args, env) {
74
- const raw = String(args.filePath ?? '').trim();
74
+ const raw = repairFilePath(rootDir, String(args.filePath ?? '').trim());
75
75
  if (!raw) {
76
76
  return { ok: false, error: 'filePath is required' };
77
77
  }
@@ -3,6 +3,7 @@ import path from 'path';
3
3
  import { normalizeProjectRelativePath, shouldIgnoreArtifactPath, } from '../artifact-policy.js';
4
4
  import { buildSecretFilePreview, isDotenvLikePath, looksLikeEditableDotenv, shouldUseSecretFilePreview, } from '../secret-preview.js';
5
5
  import { readProjectFile } from '../patcher.js';
6
+ import { repairFilePath } from './path-suggest.js';
6
7
  import { dotenvFitsRedactionBudget, getCurrentFileHash, recordReadCoverage, redactContentWithStableTokens, redactDotenvWithStableTokens, } from '../session-safety.js';
7
8
  import { readFileRange, truncate } from '../utils.js';
8
9
  const MAX_FILE_READ_CHARS = 12000;
@@ -10,7 +11,7 @@ const DOCUMENT_EXTENSIONS = new Set(['.pdf', '.xlsx', '.docx']);
10
11
  export async function readFile(context, args) {
11
12
  const rootDir = typeof context === 'string' ? context : context.rootDir;
12
13
  const safety = typeof context === 'string' ? undefined : context.safety;
13
- const filePath = String(args.filePath ?? '').trim();
14
+ const filePath = repairFilePath(rootDir, String(args.filePath ?? '').trim());
14
15
  if (!filePath) {
15
16
  return { ok: false, error: 'filePath is required' };
16
17
  }
@@ -41,13 +42,23 @@ export async function readFile(context, args) {
41
42
  content = readProjectFile(rootDir, filePath);
42
43
  }
43
44
  catch (err) {
44
- return { ok: false, error: err.message };
45
+ const message = String(err?.message ?? err);
46
+ const notFound = /^File does not exist:/.test(message);
47
+ return {
48
+ ok: false,
49
+ error: message,
50
+ ...(notFound ? { failureCategory: 'not_found' } : {}),
51
+ };
45
52
  }
46
53
  }
47
54
  else {
48
55
  const absPath = path.resolve(filePath);
49
56
  if (!existsSync(absPath)) {
50
- return { ok: false, error: `File does not exist: ${filePath}` };
57
+ return {
58
+ ok: false,
59
+ error: `File does not exist: ${filePath}`,
60
+ failureCategory: 'not_found',
61
+ };
51
62
  }
52
63
  try {
53
64
  content = readFileSync(absPath, 'utf-8');
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { isSensitiveProjectPath, normalizeProjectRelativePath, } from '../artifact-policy.js';
5
5
  import { readCliAuthConfig } from '../api/auth.js';
6
6
  import { resolveProjectPath, writeProjectFileBuffer } from '../patcher.js';
7
- import { suggestClosestPath } from './path-suggest.js';
7
+ import { repairFilePath, suggestClosestPath } from './path-suggest.js';
8
8
  import { isTuiMode } from '../runtime-mode.js';
9
9
  function normalizeReplacements(value) {
10
10
  if (!Array.isArray(value))
@@ -76,7 +76,7 @@ async function replaceDocumentTextOnServer(config, fileName, fileData, replaceme
76
76
  return data;
77
77
  }
78
78
  export async function replaceDocumentText(context, args) {
79
- const sourceRaw = String(args.filePath ?? args.file_path ?? '').trim();
79
+ const sourceRaw = repairFilePath(context.rootDir, String(args.filePath ?? args.file_path ?? '').trim());
80
80
  if (!sourceRaw) {
81
81
  return { ok: false, error: 'filePath is required' };
82
82
  }
@@ -95,6 +95,10 @@ export async function replaceDocumentText(context, args) {
95
95
  failureCategory: 'invalid_argument',
96
96
  };
97
97
  }
98
+ // outputPath is a write target, not an existing input, so it must NOT be
99
+ // path-repaired: fold-match could redirect a "create Review '24.docx" onto an
100
+ // existing Review ’24.docx and overwrite it. The executor mirrors this by not
101
+ // repairing the snapshot path when an outputPath is present.
98
102
  const outputRaw = String(args.outputPath ?? args.output_path ?? '').trim();
99
103
  const targetPath = outputRaw
100
104
  ? relativeEditablePath(context.rootDir, outputRaw)
@@ -2,6 +2,7 @@ import chalk from '../colors.js';
2
2
  import path from 'node:path';
3
3
  import { normalizeProjectRelativePath } from '../artifact-policy.js';
4
4
  import { readProjectFile, writeProjectFile } from '../patcher.js';
5
+ import { repairFilePath } from './path-suggest.js';
5
6
  import { upsertIndexFile } from '../project-index.js';
6
7
  import { isTuiMode } from '../runtime-mode.js';
7
8
  import { getCurrentFileHash, resolveRedactionTokens } from '../session-safety.js';
@@ -76,7 +77,7 @@ function buildStrReplacePreview(oldString, newString) {
76
77
  }
77
78
  export async function strReplace(context, args) {
78
79
  const { rootDir, projectIndex, autoYes, confirmPatch } = context;
79
- const filePath = String(args.filePath ?? args.file_path ?? '').trim();
80
+ const filePath = repairFilePath(rootDir, String(args.filePath ?? args.file_path ?? '').trim());
80
81
  let oldString = typeof args.old_string === 'string'
81
82
  ? args.old_string
82
83
  : typeof args.oldString === 'string'
@@ -997,7 +997,12 @@ export function getInputCommandToken(input) {
997
997
  if (!trimmed.startsWith('/'))
998
998
  return '';
999
999
  const firstSpaceIndex = trimmed.indexOf(' ');
1000
- return firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1000
+ const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1001
+ // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
1002
+ // not a slash command — no command contains a slash, so don't treat it as one.
1003
+ if (token.indexOf('/', 1) !== -1)
1004
+ return '';
1005
+ return token;
1001
1006
  }
1002
1007
  function shouldShowCommandPalette(state) {
1003
1008
  const trimmed = String(state.input ?? '').trim();
@@ -1006,7 +1011,11 @@ function shouldShowCommandPalette(state) {
1006
1011
  !state.modelPickerOpen &&
1007
1012
  !state.resumePickerOpen &&
1008
1013
  trimmed.startsWith('/') &&
1009
- !trimmed.includes(' '));
1014
+ !trimmed.includes(' ') &&
1015
+ // A '/'-prefixed token with a second '/' is a filesystem path, not a
1016
+ // command — getInputCommandToken returns '' for it, so the palette stays
1017
+ // closed when a folder path is pasted.
1018
+ getInputCommandToken(trimmed) !== '');
1010
1019
  }
1011
1020
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1012
1021
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -177,7 +177,12 @@ function buildModelPickerOptions(currentModelId, serverModels) {
177
177
  function getInputCommandToken(input) {
178
178
  const trimmed = String(input ?? '').trimStart();
179
179
  const match = trimmed.match(/^\/[^\s]*/);
180
- return match?.[0] ?? '';
180
+ const token = match?.[0] ?? '';
181
+ // A token with a second '/' is a filesystem path (e.g. /home/user/repo),
182
+ // not a slash command — no command contains a slash, so don't treat it as one.
183
+ if (token.indexOf('/', 1) !== -1)
184
+ return '';
185
+ return token;
181
186
  }
182
187
  function scoreSlashCommand(option, token) {
183
188
  if (option.command === token)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-beta.11",
3
+ "version": "1.0.0-beta.13",
4
4
  "description": "TheGitAI CLI client (source-visible, proprietary)",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://thegit.ai",
@@ -25,10 +25,10 @@
25
25
  "@lydell/node-pty-linux-x64": "1.1.0",
26
26
  "@lydell/node-pty-win32-arm64": "1.1.0",
27
27
  "@lydell/node-pty-win32-x64": "1.1.0",
28
- "@thegitai/tui-darwin-arm64": "1.0.0-beta.11",
29
- "@thegitai/tui-darwin-x64": "1.0.0-beta.11",
30
- "@thegitai/tui-linux-x64": "1.0.0-beta.11",
31
- "@thegitai/tui-win32-x64": "1.0.0-beta.11",
28
+ "@thegitai/tui-darwin-arm64": "1.0.0-beta.13",
29
+ "@thegitai/tui-darwin-x64": "1.0.0-beta.13",
30
+ "@thegitai/tui-linux-x64": "1.0.0-beta.13",
31
+ "@thegitai/tui-win32-x64": "1.0.0-beta.13",
32
32
  "@vscode/ripgrep": "1.18.0"
33
33
  },
34
34
  "publishConfig": {