@thegitai/cli 1.0.0-beta.1

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 (101) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +30 -0
  3. package/dist/bin/ai.js +438 -0
  4. package/dist/parsers/tree-sitter-c-sharp.wasm +0 -0
  5. package/dist/parsers/tree-sitter-c.wasm +0 -0
  6. package/dist/parsers/tree-sitter-cpp.wasm +0 -0
  7. package/dist/parsers/tree-sitter-css.wasm +0 -0
  8. package/dist/parsers/tree-sitter-go.wasm +0 -0
  9. package/dist/parsers/tree-sitter-html.wasm +0 -0
  10. package/dist/parsers/tree-sitter-java.wasm +0 -0
  11. package/dist/parsers/tree-sitter-javascript.wasm +0 -0
  12. package/dist/parsers/tree-sitter-objc.wasm +0 -0
  13. package/dist/parsers/tree-sitter-php.wasm +0 -0
  14. package/dist/parsers/tree-sitter-python.wasm +0 -0
  15. package/dist/parsers/tree-sitter-ruby.wasm +0 -0
  16. package/dist/parsers/tree-sitter-rust.wasm +0 -0
  17. package/dist/parsers/tree-sitter-tsx.wasm +0 -0
  18. package/dist/parsers/tree-sitter-typescript.wasm +0 -0
  19. package/dist/src/agent-mode.js +142 -0
  20. package/dist/src/api/auth.js +81 -0
  21. package/dist/src/api/browser-login.js +184 -0
  22. package/dist/src/api/chat.js +346 -0
  23. package/dist/src/api/contracts.js +1 -0
  24. package/dist/src/api/http.js +44 -0
  25. package/dist/src/api/index.js +11 -0
  26. package/dist/src/api/models.js +110 -0
  27. package/dist/src/api/sessions.js +72 -0
  28. package/dist/src/artifact-policy.js +207 -0
  29. package/dist/src/client-state.js +14 -0
  30. package/dist/src/core/clipboard.js +208 -0
  31. package/dist/src/core/open-url.js +32 -0
  32. package/dist/src/edit-journal.js +133 -0
  33. package/dist/src/executor.js +924 -0
  34. package/dist/src/extractors/cpp.js +18 -0
  35. package/dist/src/extractors/csharp.js +16 -0
  36. package/dist/src/extractors/css.js +12 -0
  37. package/dist/src/extractors/go.js +27 -0
  38. package/dist/src/extractors/index.js +52 -0
  39. package/dist/src/extractors/java.js +14 -0
  40. package/dist/src/extractors/javascript.js +33 -0
  41. package/dist/src/extractors/objc.js +14 -0
  42. package/dist/src/extractors/php.js +20 -0
  43. package/dist/src/extractors/python.js +11 -0
  44. package/dist/src/extractors/ruby.js +13 -0
  45. package/dist/src/extractors/rust.js +17 -0
  46. package/dist/src/extractors/utils.js +58 -0
  47. package/dist/src/help-text.js +125 -0
  48. package/dist/src/markdown-renderer.js +112 -0
  49. package/dist/src/patcher.js +279 -0
  50. package/dist/src/project-index.js +221 -0
  51. package/dist/src/repo-map-languages.js +100 -0
  52. package/dist/src/runtime-mode.js +35 -0
  53. package/dist/src/scanner.js +362 -0
  54. package/dist/src/secret-preview.js +137 -0
  55. package/dist/src/session-exit.js +17 -0
  56. package/dist/src/session-safety.js +1012 -0
  57. package/dist/src/session-store.js +266 -0
  58. package/dist/src/session.js +93 -0
  59. package/dist/src/tool-executor.js +188 -0
  60. package/dist/src/tools/code-intel.js +472 -0
  61. package/dist/src/tools/delete-file.js +27 -0
  62. package/dist/src/tools/exec-utils.js +17 -0
  63. package/dist/src/tools/find-symbol.js +70 -0
  64. package/dist/src/tools/get-diagnostics.js +22 -0
  65. package/dist/src/tools/grep-code.js +331 -0
  66. package/dist/src/tools/hover-symbol.js +95 -0
  67. package/dist/src/tools/index.js +73 -0
  68. package/dist/src/tools/list-checkpoints.js +11 -0
  69. package/dist/src/tools/list-directories.js +16 -0
  70. package/dist/src/tools/list-files.js +13 -0
  71. package/dist/src/tools/list-session-edits.js +9 -0
  72. package/dist/src/tools/list-symbols.js +55 -0
  73. package/dist/src/tools/patch-file.js +88 -0
  74. package/dist/src/tools/path-listing.js +83 -0
  75. package/dist/src/tools/read-document.js +111 -0
  76. package/dist/src/tools/read-file.js +109 -0
  77. package/dist/src/tools/restore-checkpoint.js +100 -0
  78. package/dist/src/tools/ripgrep.js +29 -0
  79. package/dist/src/tools/run-command.js +94 -0
  80. package/dist/src/tools/run-node-script.js +210 -0
  81. package/dist/src/tools/search-code.js +37 -0
  82. package/dist/src/tools/shell-diagnostics.js +707 -0
  83. package/dist/src/tools/signature-help.js +118 -0
  84. package/dist/src/tools/str-replace.js +193 -0
  85. package/dist/src/tools/types.js +1 -0
  86. package/dist/src/tools/undo-edit.js +202 -0
  87. package/dist/src/tools/write-file.js +59 -0
  88. package/dist/src/tree-sitter-runtime.js +135 -0
  89. package/dist/src/types.js +1 -0
  90. package/dist/src/ui/paste-collapse.js +22 -0
  91. package/dist/src/ui/prompt-history-store.js +96 -0
  92. package/dist/src/ui/repl.js +2238 -0
  93. package/dist/src/ui/tui/bridge.js +175 -0
  94. package/dist/src/ui/tui/build-frame.js +718 -0
  95. package/dist/src/ui/tui/markdown-render.js +455 -0
  96. package/dist/src/ui/tui/shell-input.js +488 -0
  97. package/dist/src/ui/tui/text.js +30 -0
  98. package/dist/src/ui/tui/types.js +1 -0
  99. package/dist/src/usage.js +47 -0
  100. package/dist/src/utils.js +38 -0
  101. package/package.json +38 -0
@@ -0,0 +1,362 @@
1
+ import { execSync } from 'child_process';
2
+ import { readFileSync, statSync } from 'fs';
3
+ import { glob } from 'glob';
4
+ import path from 'path';
5
+ 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';
7
+ const BINARY_EXTENSIONS = BINARY_ARTIFACT_EXTENSIONS;
8
+ const ALWAYS_IGNORE_FILES = ARTIFACT_IGNORE_FILES;
9
+ export const ALWAYS_IGNORE_DIRS = ARTIFACT_IGNORE_DIRS;
10
+ export const BLOCKED_PATH_INSPECT_DIRS = ARTIFACT_INSPECT_BLOCK_DIRS;
11
+ const FALLBACK_IGNORE = ARTIFACT_FALLBACK_IGNORE_GLOBS;
12
+ export const SCANNER_MAX_SOURCE_FILE_BYTES = 100 * 1024;
13
+ const MAX_FILE_SIZE = SCANNER_MAX_SOURCE_FILE_BYTES;
14
+ const MAX_CHUNKS = 2000;
15
+ const TARGET_CHUNK_CHARS = 1800;
16
+ const MAX_CHUNK_CHARS = 2800;
17
+ const FALLBACK_OVERLAP_LINES = 10;
18
+ const MAX_STRUCTURE_DEPTH = 2;
19
+ function getFiles(rootDir, { limit = Infinity } = {}) {
20
+ 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));
26
+ return Number.isFinite(limit) ? files.slice(0, limit) : files;
27
+ }
28
+ 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);
37
+ }
38
+ }
39
+ function shouldSkipFile(relPath, stat) {
40
+ if (shouldIgnorePath(relPath))
41
+ return true;
42
+ const fileName = path.basename(relPath);
43
+ if (ALWAYS_IGNORE_FILES.has(fileName))
44
+ return true;
45
+ const ext = path.extname(relPath).toLowerCase();
46
+ if (BINARY_EXTENSIONS.has(ext))
47
+ return true;
48
+ if (relPath.endsWith('.min.js') ||
49
+ relPath.endsWith('.min.css') ||
50
+ relPath.endsWith('.chunk.js') ||
51
+ relPath.endsWith('.bundle.js')) {
52
+ return true;
53
+ }
54
+ return stat.size > MAX_FILE_SIZE;
55
+ }
56
+ function getRangeCharCount(lines, startLine, endLine) {
57
+ let total = 0;
58
+ for (let lineNum = startLine; lineNum <= endLine; lineNum++) {
59
+ total += (lines[lineNum - 1] ?? '').length + 1;
60
+ }
61
+ return total;
62
+ }
63
+ function normalizeLineRange(lines, startLine, endLine) {
64
+ let start = Math.max(1, startLine);
65
+ let end = Math.min(endLine, lines.length);
66
+ while (start <= end && !(lines[start - 1] ?? '').trim())
67
+ start++;
68
+ while (end >= start && !(lines[end - 1] ?? '').trim())
69
+ end--;
70
+ return start <= end ? { startLine: start, endLine: end } : null;
71
+ }
72
+ function createChunkFromRange(relPath, lines, startLine, endLine, label, kind = 'context') {
73
+ const range = normalizeLineRange(lines, startLine, endLine);
74
+ if (!range)
75
+ return null;
76
+ return {
77
+ filePath: relPath,
78
+ content: lines.slice(range.startLine - 1, range.endLine).join('\n'),
79
+ startLine: range.startLine,
80
+ endLine: range.endLine,
81
+ label,
82
+ kind,
83
+ };
84
+ }
85
+ function createSegmentUnit(lines, startLine, endLine, label, kind, node) {
86
+ const range = normalizeLineRange(lines, startLine, endLine);
87
+ if (!range)
88
+ return null;
89
+ return {
90
+ startLine: range.startLine,
91
+ endLine: range.endLine,
92
+ charCount: getRangeCharCount(lines, range.startLine, range.endLine),
93
+ label,
94
+ kind,
95
+ node,
96
+ };
97
+ }
98
+ function deriveChunkLabel(units) {
99
+ const labels = [...new Set(units.map((unit) => unit.label).filter(Boolean))];
100
+ return labels.length === 1 ? labels[0] : undefined;
101
+ }
102
+ function shouldMergeUnits(lines, currentUnits, nextUnit) {
103
+ if (!currentUnits.length)
104
+ return true;
105
+ const currentStart = currentUnits[0].startLine;
106
+ const currentEnd = currentUnits[currentUnits.length - 1].endLine;
107
+ if (nextUnit.startLine - currentEnd > 3) {
108
+ return false;
109
+ }
110
+ const mergedChars = getRangeCharCount(lines, currentStart, nextUnit.endLine);
111
+ if (mergedChars > MAX_CHUNK_CHARS) {
112
+ return false;
113
+ }
114
+ const hasContext = currentUnits.some((unit) => unit.kind === 'context') ||
115
+ nextUnit.kind === 'context';
116
+ if (!hasContext) {
117
+ return false;
118
+ }
119
+ if (mergedChars > TARGET_CHUNK_CHARS) {
120
+ return false;
121
+ }
122
+ return (!currentUnits.some((unit) => unit.kind === 'symbol') ||
123
+ nextUnit.kind === 'context');
124
+ }
125
+ function buildSlidingWindowChunks(relPath, lines, startLine, endLine, label, kind = 'context') {
126
+ const chunks = [];
127
+ const range = normalizeLineRange(lines, startLine, endLine);
128
+ if (!range)
129
+ return chunks;
130
+ let cursor = range.startLine;
131
+ while (cursor <= range.endLine) {
132
+ let chunkEnd = cursor - 1;
133
+ let charCount = 0;
134
+ while (chunkEnd < range.endLine) {
135
+ const nextLineNum = chunkEnd + 1;
136
+ const nextChars = (lines[nextLineNum - 1] ?? '').length + 1;
137
+ if (charCount >= TARGET_CHUNK_CHARS &&
138
+ charCount + nextChars > MAX_CHUNK_CHARS) {
139
+ break;
140
+ }
141
+ chunkEnd = nextLineNum;
142
+ charCount += nextChars;
143
+ if (charCount >= TARGET_CHUNK_CHARS) {
144
+ const lineText = (lines[chunkEnd - 1] ?? '').trim();
145
+ if (!lineText ||
146
+ /^[}\])]+[;,]?$/.test(lineText) ||
147
+ /^(export |function |class |interface |type |const |let |var |async function |def |async def |pub |impl |trait |enum |struct )/.test(lineText)) {
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ if (chunkEnd < cursor) {
153
+ chunkEnd = cursor;
154
+ }
155
+ const chunk = createChunkFromRange(relPath, lines, cursor, chunkEnd, label, kind);
156
+ if (chunk) {
157
+ chunks.push(chunk);
158
+ }
159
+ if (chunkEnd >= range.endLine) {
160
+ break;
161
+ }
162
+ cursor = Math.max(cursor + 1, chunkEnd - FALLBACK_OVERLAP_LINES + 1);
163
+ }
164
+ return chunks;
165
+ }
166
+ function buildUnitsFromNode(node, lines, languageId) {
167
+ const children = getStructuralChildren(node);
168
+ const nodeStartLine = (node?.startPosition?.row ?? 0) + 1;
169
+ const nodeEndLine = (node?.endPosition?.row ?? 0) + 1;
170
+ const signature = getNodePrimarySignature(node, languageId) ?? undefined;
171
+ if (!children.length) {
172
+ const onlyUnit = createSegmentUnit(lines, nodeStartLine, nodeEndLine, signature, signature ? 'symbol' : 'context', node);
173
+ return onlyUnit ? [onlyUnit] : [];
174
+ }
175
+ const units = [];
176
+ const firstChildStartLine = (children[0]?.startPosition?.row ?? 0) + 1;
177
+ const headerUnit = createSegmentUnit(lines, nodeStartLine, firstChildStartLine - 1, signature, 'context', null);
178
+ if (headerUnit) {
179
+ units.push(headerUnit);
180
+ }
181
+ for (const child of children) {
182
+ const label = getNodePrimarySignature(child, languageId) ?? undefined;
183
+ const childUnit = createSegmentUnit(lines, (child?.startPosition?.row ?? 0) + 1, (child?.endPosition?.row ?? 0) + 1, label, label ? 'symbol' : 'context', child);
184
+ if (childUnit) {
185
+ units.push(childUnit);
186
+ }
187
+ }
188
+ const lastChildEndLine = (children[children.length - 1]?.endPosition?.row ?? nodeEndLine - 1) + 1;
189
+ const footerUnit = createSegmentUnit(lines, lastChildEndLine + 1, nodeEndLine, undefined, 'context', null);
190
+ if (footerUnit) {
191
+ units.push(footerUnit);
192
+ }
193
+ return units;
194
+ }
195
+ function buildChunksFromUnits(relPath, lines, units, languageId, depth) {
196
+ const chunks = [];
197
+ let currentUnits = [];
198
+ function flush() {
199
+ if (!currentUnits.length)
200
+ return;
201
+ const label = deriveChunkLabel(currentUnits);
202
+ const kind = currentUnits.every((unit) => unit.kind === 'symbol') && label
203
+ ? 'symbol'
204
+ : 'context';
205
+ const chunk = createChunkFromRange(relPath, lines, currentUnits[0].startLine, currentUnits[currentUnits.length - 1].endLine, label, kind);
206
+ if (chunk) {
207
+ chunks.push(chunk);
208
+ }
209
+ currentUnits = [];
210
+ }
211
+ for (const unit of units) {
212
+ if (unit.charCount > MAX_CHUNK_CHARS) {
213
+ flush();
214
+ const nestedUnits = unit.node && depth < MAX_STRUCTURE_DEPTH
215
+ ? buildUnitsFromNode(unit.node, lines, languageId)
216
+ : [];
217
+ const madeProgress = nestedUnits.some((nested) => nested.startLine > unit.startLine || nested.endLine < unit.endLine);
218
+ if (nestedUnits.length >= 2 && madeProgress) {
219
+ chunks.push(...buildChunksFromUnits(relPath, lines, nestedUnits, languageId, depth + 1));
220
+ continue;
221
+ }
222
+ chunks.push(...buildSlidingWindowChunks(relPath, lines, unit.startLine, unit.endLine, unit.label, unit.kind));
223
+ continue;
224
+ }
225
+ if (shouldMergeUnits(lines, currentUnits, unit)) {
226
+ currentUnits.push(unit);
227
+ continue;
228
+ }
229
+ flush();
230
+ currentUnits.push(unit);
231
+ }
232
+ flush();
233
+ return chunks;
234
+ }
235
+ async function chunkFileContent(relPath, content) {
236
+ const lines = content.split('\n');
237
+ const parsed = await parseRepoSource(relPath, content);
238
+ if (parsed) {
239
+ const units = buildUnitsFromNode(parsed.tree.rootNode, lines, parsed.languageId);
240
+ const chunks = buildChunksFromUnits(relPath, lines, units, parsed.languageId, 0);
241
+ if (chunks.length > 0) {
242
+ return chunks;
243
+ }
244
+ }
245
+ return buildSlidingWindowChunks(relPath, lines, 1, lines.length);
246
+ }
247
+ export function shouldIgnorePath(relPath) {
248
+ return shouldIgnoreArtifactPath(relPath) || isSensitiveProjectPath(relPath);
249
+ }
250
+ export function listProjectFiles(rootDir, opts = {}) {
251
+ return getFiles(rootDir, opts);
252
+ }
253
+ export async function scanFiles(rootDir, relPaths) {
254
+ const chunks = [];
255
+ for (const relPath of relPaths) {
256
+ const absPath = path.join(rootDir, relPath);
257
+ let stat;
258
+ try {
259
+ stat = statSync(absPath);
260
+ }
261
+ catch {
262
+ continue;
263
+ }
264
+ if (!stat.isFile() || shouldSkipFile(relPath, stat))
265
+ continue;
266
+ let content;
267
+ try {
268
+ content = readFileSync(absPath, 'utf-8');
269
+ }
270
+ catch {
271
+ continue;
272
+ }
273
+ if (isBinary(content))
274
+ continue;
275
+ chunks.push(...(await chunkFileContent(relPath, content)));
276
+ }
277
+ return chunks;
278
+ }
279
+ export async function scanProject(rootDir) {
280
+ const files = getFiles(rootDir);
281
+ const chunks = await scanFiles(rootDir, files);
282
+ if (chunks.length > MAX_CHUNKS) {
283
+ return chunks.slice(0, MAX_CHUNKS);
284
+ }
285
+ return chunks;
286
+ }
287
+ export function estimateChunkUpperBoundForPath(rootDir, relPath) {
288
+ return getPathIndexMetadata(rootDir, relPath).estimatedChunks;
289
+ }
290
+ function getPathIndexMetadata(rootDir, relPath) {
291
+ const depth = relPath.split(/[/\\]+/).filter(Boolean).length;
292
+ try {
293
+ const absPath = path.join(rootDir, relPath);
294
+ const stat = statSync(absPath);
295
+ if (!stat.isFile() || shouldSkipFile(relPath, stat)) {
296
+ return { depth, estimatedChunks: 0, size: stat.size };
297
+ }
298
+ const capped = Math.min(stat.size, MAX_FILE_SIZE);
299
+ return {
300
+ depth,
301
+ estimatedChunks: Math.max(1, Math.ceil(capped / TARGET_CHUNK_CHARS)),
302
+ size: stat.size,
303
+ };
304
+ }
305
+ catch {
306
+ return { depth, estimatedChunks: 0, size: Number.MAX_SAFE_INTEGER };
307
+ }
308
+ }
309
+ export function buildPathIndexMetadata(rootDir, relPaths) {
310
+ const metadata = new Map();
311
+ for (const relPath of relPaths) {
312
+ metadata.set(relPath, getPathIndexMetadata(rootDir, relPath));
313
+ }
314
+ return metadata;
315
+ }
316
+ export function estimateChunkCountUpperBoundForFiles(rootDir, relPaths) {
317
+ let n = 0;
318
+ const metadata = buildPathIndexMetadata(rootDir, relPaths);
319
+ for (const relPath of relPaths) {
320
+ n += metadata.get(relPath)?.estimatedChunks ?? 0;
321
+ }
322
+ return n;
323
+ }
324
+ export function sortPathsForIndexedCoverage(rootDir, relPaths, metadata = buildPathIndexMetadata(rootDir, relPaths)) {
325
+ return [...relPaths].sort((a, b) => {
326
+ const ma = metadata.get(a) ?? getPathIndexMetadata(rootDir, a);
327
+ const mb = metadata.get(b) ?? getPathIndexMetadata(rootDir, b);
328
+ if (ma.depth !== mb.depth)
329
+ return ma.depth - mb.depth;
330
+ if (ma.size !== mb.size)
331
+ return ma.size - mb.size;
332
+ return a.localeCompare(b);
333
+ });
334
+ }
335
+ export function takePathsForChunkBudget(rootDir, orderedPaths, maxChunks, metadata = buildPathIndexMetadata(rootDir, orderedPaths)) {
336
+ if (maxChunks <= 0) {
337
+ return { selected: [], rest: [...orderedPaths] };
338
+ }
339
+ if (!Number.isFinite(maxChunks)) {
340
+ return { selected: [...orderedPaths], rest: [] };
341
+ }
342
+ const selected = [];
343
+ let used = 0;
344
+ for (const p of orderedPaths) {
345
+ const add = metadata.get(p)?.estimatedChunks ??
346
+ getPathIndexMetadata(rootDir, p).estimatedChunks;
347
+ if (add <= 0)
348
+ continue;
349
+ if (used + add > maxChunks && selected.length > 0)
350
+ break;
351
+ selected.push(p);
352
+ used += add;
353
+ if (used >= maxChunks)
354
+ break;
355
+ }
356
+ const sel = new Set(selected);
357
+ return { selected, rest: orderedPaths.filter((p) => !sel.has(p)) };
358
+ }
359
+ function isBinary(content) {
360
+ const sample = content.slice(0, 8192);
361
+ return sample.includes('\0');
362
+ }
@@ -0,0 +1,137 @@
1
+ import path from 'node:path';
2
+ import { isSensitiveProjectPath } from './artifact-policy.js';
3
+ export const SECRET_FILE_REDACTION = '[REDACTED: secrets file]';
4
+ const VALUE_REDACTION = '[REDACTED]';
5
+ const PRIVATE_KEY_REDACTION = '[REDACTED: private key]';
6
+ const SENSITIVE_JSON_KEY_PATTERN = /^(?:private[_-]?key|secret|api[_-]?key|password|client_secret|refresh_token|access_token|id_token|auth_provider_x509_cert_url)$/i;
7
+ const PEM_BLOCK_PATTERN = /-----BEGIN [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)[\s\S]*?-----END [^-]*(?:PRIVATE KEY|SECRET KEY|OPENSSH PRIVATE KEY)-----/gi;
8
+ const PEM_SECRET_PATH_PATTERN = /\.(?:pem|key)$/i;
9
+ // Password embedded in a connection-string URL, e.g.
10
+ // `postgresql://user:PASS@host`. Redacted from shell output so secrets in
11
+ // commands like `cat .env` do not leak into history or telemetry.
12
+ const URL_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s/@]+)(@)/gi;
13
+ /** Redact only userinfo passwords in connection-string URLs (zero false positives). */
14
+ export function redactConnectionStringCredentials(text) {
15
+ return text.replace(URL_CREDENTIALS_PATTERN, (_match, prefix, _password, at) => `${prefix}${VALUE_REDACTION}${at}`);
16
+ }
17
+ function isSensitiveJsonKey(key) {
18
+ return SENSITIVE_JSON_KEY_PATTERN.test(key);
19
+ }
20
+ function previewJsonValue(value) {
21
+ if (typeof value === 'string') {
22
+ PEM_BLOCK_PATTERN.lastIndex = 0;
23
+ if (PEM_BLOCK_PATTERN.test(value))
24
+ return PRIVATE_KEY_REDACTION;
25
+ return value.length > 160 ? `${value.slice(0, 160)}…` : value;
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return value.map((entry) => typeof entry === 'string' || typeof entry === 'number' || typeof entry === 'boolean'
29
+ ? previewJsonValue(entry)
30
+ : '[REDACTED: nested value]');
31
+ }
32
+ if (value && typeof value === 'object') {
33
+ return '[REDACTED: nested object]';
34
+ }
35
+ return value;
36
+ }
37
+ export function isCredentialJsonContent(content) {
38
+ const trimmed = content.trim();
39
+ if (!trimmed.startsWith('{'))
40
+ return false;
41
+ try {
42
+ const parsed = JSON.parse(trimmed);
43
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
44
+ return false;
45
+ const record = parsed;
46
+ const type = String(record.type ?? '').toLowerCase();
47
+ if (type === 'service_account' ||
48
+ type === 'authorized_user' ||
49
+ type === 'external_account') {
50
+ return true;
51
+ }
52
+ return Object.keys(record).some((key) => isSensitiveJsonKey(key));
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ export function shouldUseSecretFilePreview(filePath, content) {
59
+ const normalized = String(filePath ?? '')
60
+ .replace(/\\/g, '/')
61
+ .trim();
62
+ return (isSensitiveProjectPath(normalized) ||
63
+ isSensitiveProjectPath(path.posix.basename(normalized)) ||
64
+ isCredentialJsonContent(content));
65
+ }
66
+ const DOTENV_BASENAME_PATTERN = /^\.env(?:\..+)?$/i;
67
+ const DOTENV_ASSIGNMENT_PATTERN = /^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=)(.*)$/;
68
+ export function isDotenvLikePath(value) {
69
+ const text = String(value ?? '').trim();
70
+ if (!text)
71
+ return false;
72
+ const base = path.posix.basename(text.replace(/\\/g, '/'));
73
+ return DOTENV_BASENAME_PATTERN.test(base);
74
+ }
75
+ /**
76
+ * True only for a clean dotenv file we can safely show with keys visible and
77
+ * values tokenized: no PEM block, not JSON, and every non-blank/non-comment line
78
+ * is a `KEY=VALUE` assignment. Anything ambiguous (a stray line that might be a
79
+ * raw secret) returns false so the caller keeps the opaque blackout instead.
80
+ */
81
+ export function looksLikeEditableDotenv(content) {
82
+ PEM_BLOCK_PATTERN.lastIndex = 0;
83
+ if (PEM_BLOCK_PATTERN.test(content))
84
+ return false;
85
+ if (content.trimStart().startsWith('{'))
86
+ return false;
87
+ let sawAssignment = false;
88
+ for (const line of content.split('\n')) {
89
+ const trimmed = line.trim();
90
+ if (trimmed === '' || trimmed.startsWith('#'))
91
+ continue;
92
+ if (!DOTENV_ASSIGNMENT_PATTERN.test(line))
93
+ return false;
94
+ sawAssignment = true;
95
+ }
96
+ return sawAssignment;
97
+ }
98
+ export function buildSecretFilePreview(filePath, content) {
99
+ PEM_BLOCK_PATTERN.lastIndex = 0;
100
+ if (!content.trimStart().startsWith('{') &&
101
+ (PEM_BLOCK_PATTERN.test(content) || PEM_SECRET_PATH_PATTERN.test(filePath))) {
102
+ return {
103
+ kind: 'pem',
104
+ filePath,
105
+ redacted: true,
106
+ keys: ['private_key_block'],
107
+ preview: { private_key_block: PRIVATE_KEY_REDACTION },
108
+ };
109
+ }
110
+ try {
111
+ const parsed = JSON.parse(content);
112
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
113
+ const record = parsed;
114
+ const keys = Object.keys(record);
115
+ const preview = {};
116
+ for (const key of keys) {
117
+ preview[key] = isSensitiveJsonKey(key)
118
+ ? VALUE_REDACTION
119
+ : previewJsonValue(record[key]);
120
+ }
121
+ return {
122
+ kind: 'json-credentials',
123
+ filePath,
124
+ redacted: true,
125
+ keys,
126
+ preview,
127
+ };
128
+ }
129
+ }
130
+ catch { }
131
+ return {
132
+ kind: 'opaque-secret-file',
133
+ filePath,
134
+ redacted: true,
135
+ output: SECRET_FILE_REDACTION,
136
+ };
137
+ }
@@ -0,0 +1,17 @@
1
+ export function formatSessionResumeCommand(sessionId, executableName = 'ai') {
2
+ const normalizedSessionId = String(sessionId ?? '').trim();
3
+ if (!normalizedSessionId) {
4
+ throw new Error('Session id is required.');
5
+ }
6
+ return `${executableName} --resume ${normalizedSessionId}`;
7
+ }
8
+ export function formatSessionExitNotice(sessionId, executableName = 'ai') {
9
+ const normalizedSessionId = String(sessionId ?? '').trim();
10
+ if (!normalizedSessionId) {
11
+ throw new Error('Session id is required.');
12
+ }
13
+ return [
14
+ `Session ID: ${normalizedSessionId}`,
15
+ `To resume this session, run ${formatSessionResumeCommand(normalizedSessionId, executableName)}`,
16
+ ].join('\n');
17
+ }