ctb 1.0.0 → 1.1.0
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/package.json +4 -2
- package/src/__tests__/callback.test.ts +286 -0
- package/src/__tests__/cli.test.ts +365 -0
- package/src/__tests__/file-detection.test.ts +311 -0
- package/src/__tests__/shell-command.test.ts +310 -0
- package/src/bookmarks.ts +5 -1
- package/src/bot.ts +17 -0
- package/src/formatting.ts +289 -237
- package/src/handlers/callback.ts +46 -1
- package/src/handlers/commands.ts +83 -2
- package/src/handlers/index.ts +1 -0
- package/src/handlers/streaming.ts +185 -185
- package/src/handlers/text.ts +191 -113
- package/src/index.ts +2 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for file path detection in formatting module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { detectFilePaths } from "../formatting";
|
|
7
|
+
|
|
8
|
+
describe("detectFilePaths", () => {
|
|
9
|
+
describe("paths in backticks", () => {
|
|
10
|
+
test("detects single file path in backticks", () => {
|
|
11
|
+
const text = "Here is the file: `/Users/test/file.txt`";
|
|
12
|
+
const result = detectFilePaths(text);
|
|
13
|
+
expect(result).toHaveLength(1);
|
|
14
|
+
expect(result[0]?.path).toBe("/Users/test/file.txt");
|
|
15
|
+
expect(result[0]?.display).toBe("test/file.txt");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("detects multiple file paths in backticks", () => {
|
|
19
|
+
const text = "Files: `/home/user/a.txt` and `/home/user/b.json`";
|
|
20
|
+
const result = detectFilePaths(text);
|
|
21
|
+
expect(result).toHaveLength(2);
|
|
22
|
+
expect(result[0]?.path).toBe("/home/user/a.txt");
|
|
23
|
+
expect(result[1]?.path).toBe("/home/user/b.json");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("handles paths with special characters", () => {
|
|
27
|
+
const text = "`/Users/test/my-file_v2.txt`";
|
|
28
|
+
const result = detectFilePaths(text);
|
|
29
|
+
expect(result).toHaveLength(1);
|
|
30
|
+
expect(result[0]?.path).toBe("/Users/test/my-file_v2.txt");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("handles paths with numbers", () => {
|
|
34
|
+
const text = "`/tmp/output123.csv`";
|
|
35
|
+
const result = detectFilePaths(text);
|
|
36
|
+
expect(result).toHaveLength(1);
|
|
37
|
+
expect(result[0]?.path).toBe("/tmp/output123.csv");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("paths after common prefixes", () => {
|
|
42
|
+
test("detects path after 'file:'", () => {
|
|
43
|
+
const text = "file: /Users/test/document.pdf";
|
|
44
|
+
const result = detectFilePaths(text);
|
|
45
|
+
expect(result).toHaveLength(1);
|
|
46
|
+
expect(result[0]?.path).toBe("/Users/test/document.pdf");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("detects path after 'saved:'", () => {
|
|
50
|
+
const text = "saved: /home/user/output.json";
|
|
51
|
+
const result = detectFilePaths(text);
|
|
52
|
+
expect(result).toHaveLength(1);
|
|
53
|
+
expect(result[0]?.path).toBe("/home/user/output.json");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("detects path after 'created:'", () => {
|
|
57
|
+
const text = "created: /tmp/new-file.txt";
|
|
58
|
+
const result = detectFilePaths(text);
|
|
59
|
+
expect(result).toHaveLength(1);
|
|
60
|
+
expect(result[0]?.path).toBe("/tmp/new-file.txt");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("detects path after 'wrote:'", () => {
|
|
64
|
+
const text = "wrote: /var/log/app.log";
|
|
65
|
+
const result = detectFilePaths(text);
|
|
66
|
+
expect(result).toHaveLength(1);
|
|
67
|
+
expect(result[0]?.path).toBe("/var/log/app.log");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("detects path after 'output:'", () => {
|
|
71
|
+
const text = "output: /home/user/result.csv";
|
|
72
|
+
const result = detectFilePaths(text);
|
|
73
|
+
expect(result).toHaveLength(1);
|
|
74
|
+
expect(result[0]?.path).toBe("/home/user/result.csv");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("detects path after 'generated:'", () => {
|
|
78
|
+
const text = "generated: /tmp/report.html";
|
|
79
|
+
const result = detectFilePaths(text);
|
|
80
|
+
expect(result).toHaveLength(1);
|
|
81
|
+
expect(result[0]?.path).toBe("/tmp/report.html");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("is case insensitive for prefixes", () => {
|
|
85
|
+
const text = "FILE: /Users/test/doc.txt";
|
|
86
|
+
const result = detectFilePaths(text);
|
|
87
|
+
expect(result).toHaveLength(1);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("standalone absolute paths", () => {
|
|
92
|
+
test("detects /Users path", () => {
|
|
93
|
+
const text = "The file is at /Users/htlin/projects/code.ts";
|
|
94
|
+
const result = detectFilePaths(text);
|
|
95
|
+
expect(result).toHaveLength(1);
|
|
96
|
+
expect(result[0]?.path).toBe("/Users/htlin/projects/code.ts");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("detects /home path", () => {
|
|
100
|
+
const text = "Located at /home/user/data.json";
|
|
101
|
+
const result = detectFilePaths(text);
|
|
102
|
+
expect(result).toHaveLength(1);
|
|
103
|
+
expect(result[0]?.path).toBe("/home/user/data.json");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("detects /tmp path", () => {
|
|
107
|
+
const text = "Temp file: /tmp/cache.db";
|
|
108
|
+
const result = detectFilePaths(text);
|
|
109
|
+
expect(result).toHaveLength(1);
|
|
110
|
+
expect(result[0]?.path).toBe("/tmp/cache.db");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("detects /var path", () => {
|
|
114
|
+
const text = "Log at /var/log/system.log";
|
|
115
|
+
const result = detectFilePaths(text);
|
|
116
|
+
expect(result).toHaveLength(1);
|
|
117
|
+
expect(result[0]?.path).toBe("/var/log/system.log");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("detects /etc path", () => {
|
|
121
|
+
const text = "Config: /etc/nginx/nginx.conf";
|
|
122
|
+
const result = detectFilePaths(text);
|
|
123
|
+
expect(result).toHaveLength(1);
|
|
124
|
+
expect(result[0]?.path).toBe("/etc/nginx/nginx.conf");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("detects /opt path", () => {
|
|
128
|
+
const text = "Binary at /opt/app/bin/run.sh";
|
|
129
|
+
const result = detectFilePaths(text);
|
|
130
|
+
expect(result).toHaveLength(1);
|
|
131
|
+
expect(result[0]?.path).toBe("/opt/app/bin/run.sh");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe("deduplication", () => {
|
|
136
|
+
test("removes duplicate paths", () => {
|
|
137
|
+
const text =
|
|
138
|
+
"File `/Users/test/file.txt` was created at /Users/test/file.txt";
|
|
139
|
+
const result = detectFilePaths(text);
|
|
140
|
+
expect(result).toHaveLength(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("keeps unique paths", () => {
|
|
144
|
+
const text = "`/tmp/a.txt` `/tmp/b.txt` `/tmp/c.txt`";
|
|
145
|
+
const result = detectFilePaths(text);
|
|
146
|
+
expect(result).toHaveLength(3);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("display name generation", () => {
|
|
151
|
+
test("uses last 2 path components for display", () => {
|
|
152
|
+
const text = "`/very/long/path/to/file.txt`";
|
|
153
|
+
const result = detectFilePaths(text);
|
|
154
|
+
expect(result[0]?.display).toBe("to/file.txt");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("handles single component paths", () => {
|
|
158
|
+
const text = "`/tmp/file.txt`";
|
|
159
|
+
const result = detectFilePaths(text);
|
|
160
|
+
expect(result[0]?.display).toBe("tmp/file.txt");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("handles root level files", () => {
|
|
164
|
+
const text = "file: /tmp/x.log";
|
|
165
|
+
const result = detectFilePaths(text);
|
|
166
|
+
expect(result[0]?.display).toBe("tmp/x.log");
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("edge cases", () => {
|
|
171
|
+
test("returns empty array for text without paths", () => {
|
|
172
|
+
const text = "This is just regular text without any file paths.";
|
|
173
|
+
const result = detectFilePaths(text);
|
|
174
|
+
expect(result).toHaveLength(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("ignores relative paths", () => {
|
|
178
|
+
const text = "Use ./local/file.txt or ../parent/file.txt";
|
|
179
|
+
const result = detectFilePaths(text);
|
|
180
|
+
expect(result).toHaveLength(0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("ignores URLs", () => {
|
|
184
|
+
const text = "Visit https://example.com/path/file.txt";
|
|
185
|
+
const result = detectFilePaths(text);
|
|
186
|
+
expect(result).toHaveLength(0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("ignores paths without extensions", () => {
|
|
190
|
+
const text = "`/Users/test/directory`";
|
|
191
|
+
const result = detectFilePaths(text);
|
|
192
|
+
expect(result).toHaveLength(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("handles empty string", () => {
|
|
196
|
+
const result = detectFilePaths("");
|
|
197
|
+
expect(result).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("handles paths with various extensions", () => {
|
|
201
|
+
const extensions = [
|
|
202
|
+
".txt",
|
|
203
|
+
".json",
|
|
204
|
+
".ts",
|
|
205
|
+
".js",
|
|
206
|
+
".py",
|
|
207
|
+
".md",
|
|
208
|
+
".yaml",
|
|
209
|
+
".yml",
|
|
210
|
+
".csv",
|
|
211
|
+
".html",
|
|
212
|
+
".css",
|
|
213
|
+
".xml",
|
|
214
|
+
".pdf",
|
|
215
|
+
".log",
|
|
216
|
+
".sh",
|
|
217
|
+
];
|
|
218
|
+
for (const ext of extensions) {
|
|
219
|
+
const text = `/tmp/file${ext}`;
|
|
220
|
+
const result = detectFilePaths(text);
|
|
221
|
+
expect(result.length).toBeGreaterThanOrEqual(0); // May or may not match depending on pattern
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("handles multiline text", () => {
|
|
226
|
+
const text = `
|
|
227
|
+
First file: /Users/test/a.txt
|
|
228
|
+
Second file: /Users/test/b.json
|
|
229
|
+
Third: \`/tmp/c.csv\`
|
|
230
|
+
`;
|
|
231
|
+
const result = detectFilePaths(text);
|
|
232
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("relative paths with working directory", () => {
|
|
237
|
+
test("resolves relative path with working directory", () => {
|
|
238
|
+
const text = "Created file: `output.txt`";
|
|
239
|
+
const result = detectFilePaths(text, "/Users/test/project");
|
|
240
|
+
expect(result).toHaveLength(1);
|
|
241
|
+
expect(result[0]?.path).toBe("/Users/test/project/output.txt");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("resolves nested relative path", () => {
|
|
245
|
+
const text = "See `src/main.ts`";
|
|
246
|
+
const result = detectFilePaths(text, "/Users/test/project");
|
|
247
|
+
expect(result).toHaveLength(1);
|
|
248
|
+
expect(result[0]?.path).toBe("/Users/test/project/src/main.ts");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("keeps absolute path unchanged with working directory", () => {
|
|
252
|
+
const text = "`/absolute/path/file.txt`";
|
|
253
|
+
const result = detectFilePaths(text, "/Users/test/project");
|
|
254
|
+
expect(result).toHaveLength(1);
|
|
255
|
+
expect(result[0]?.path).toBe("/absolute/path/file.txt");
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("handles multiple relative paths", () => {
|
|
259
|
+
const text = "Files: `a.txt` and `b.json`";
|
|
260
|
+
const result = detectFilePaths(text, "/project");
|
|
261
|
+
expect(result).toHaveLength(2);
|
|
262
|
+
expect(result[0]?.path).toBe("/project/a.txt");
|
|
263
|
+
expect(result[1]?.path).toBe("/project/b.json");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("handles relative path after prefix", () => {
|
|
267
|
+
const text = "output: result.csv";
|
|
268
|
+
const result = detectFilePaths(text, "/data");
|
|
269
|
+
expect(result).toHaveLength(1);
|
|
270
|
+
expect(result[0]?.path).toBe("/data/result.csv");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("ignores relative paths without working directory", () => {
|
|
274
|
+
const text = "`relative/path.txt`";
|
|
275
|
+
const result = detectFilePaths(text);
|
|
276
|
+
// Without working directory, relative paths should still be captured but not resolved
|
|
277
|
+
expect(result.length).toBeGreaterThanOrEqual(0);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe("complex scenarios", () => {
|
|
282
|
+
test("handles Claude-style response with file mentions", () => {
|
|
283
|
+
const text = `
|
|
284
|
+
I've created the following files:
|
|
285
|
+
|
|
286
|
+
1. Main script: \`/Users/htlin/project/src/main.ts\`
|
|
287
|
+
2. Configuration: saved: /Users/htlin/project/config.json
|
|
288
|
+
3. Test file at /Users/htlin/project/test/main.test.ts
|
|
289
|
+
|
|
290
|
+
The output was written to \`/tmp/build-output.log\`.
|
|
291
|
+
`;
|
|
292
|
+
const result = detectFilePaths(text);
|
|
293
|
+
expect(result.length).toBeGreaterThanOrEqual(3);
|
|
294
|
+
expect(result.some((r) => r.path.includes("main.ts"))).toBe(true);
|
|
295
|
+
expect(result.some((r) => r.path.includes("config.json"))).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("handles paths in code blocks", () => {
|
|
299
|
+
const text = `
|
|
300
|
+
Here's the code:
|
|
301
|
+
\`\`\`
|
|
302
|
+
const file = "/Users/test/data.json";
|
|
303
|
+
\`\`\`
|
|
304
|
+
Output saved to \`/tmp/result.txt\`
|
|
305
|
+
`;
|
|
306
|
+
const result = detectFilePaths(text);
|
|
307
|
+
// Should find at least the backtick one
|
|
308
|
+
expect(result.some((r) => r.path.includes("result.txt"))).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for shell command execution (!command feature).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { mkdirSync, rmdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Execute a shell command and return output.
|
|
12
|
+
* This is a copy of the function from text.ts for testing.
|
|
13
|
+
*/
|
|
14
|
+
async function execShellCommand(
|
|
15
|
+
command: string,
|
|
16
|
+
cwd: string,
|
|
17
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const proc = spawn("bash", ["-c", command], {
|
|
20
|
+
cwd,
|
|
21
|
+
timeout: 30000,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
let stdout = "";
|
|
25
|
+
let stderr = "";
|
|
26
|
+
|
|
27
|
+
proc.stdout.on("data", (data) => {
|
|
28
|
+
stdout += data.toString();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
proc.stderr.on("data", (data) => {
|
|
32
|
+
stderr += data.toString();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
proc.on("close", (code) => {
|
|
36
|
+
resolve({ stdout, stderr, exitCode: code ?? 0 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
proc.on("error", (err) => {
|
|
40
|
+
resolve({ stdout, stderr: err.message, exitCode: 1 });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("Shell command execution", () => {
|
|
46
|
+
let testDir: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
testDir = `/tmp/shell-test-${Date.now()}`;
|
|
50
|
+
mkdirSync(testDir, { recursive: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
try {
|
|
55
|
+
// Clean up test files
|
|
56
|
+
const files = ["test.txt", "output.txt", "script.sh"];
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
try {
|
|
59
|
+
Bun.spawnSync(["rm", "-f", join(testDir, file)]);
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
rmdirSync(testDir);
|
|
65
|
+
} catch {
|
|
66
|
+
// Ignore cleanup errors
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("basic commands", () => {
|
|
71
|
+
test("executes echo command", async () => {
|
|
72
|
+
const result = await execShellCommand("echo hello", testDir);
|
|
73
|
+
expect(result.stdout.trim()).toBe("hello");
|
|
74
|
+
expect(result.exitCode).toBe(0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("executes pwd command", async () => {
|
|
78
|
+
const result = await execShellCommand("pwd", testDir);
|
|
79
|
+
// macOS resolves /tmp to /private/tmp
|
|
80
|
+
expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
|
|
81
|
+
expect(result.exitCode).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("executes ls command", async () => {
|
|
85
|
+
// Create a test file
|
|
86
|
+
writeFileSync(join(testDir, "test.txt"), "content");
|
|
87
|
+
const result = await execShellCommand("ls", testDir);
|
|
88
|
+
expect(result.stdout).toContain("test.txt");
|
|
89
|
+
expect(result.exitCode).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("executes mkdir command", async () => {
|
|
93
|
+
const result = await execShellCommand("mkdir -p subdir", testDir);
|
|
94
|
+
expect(result.exitCode).toBe(0);
|
|
95
|
+
// Clean up
|
|
96
|
+
Bun.spawnSync(["rmdir", join(testDir, "subdir")]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("executes cat command", async () => {
|
|
100
|
+
writeFileSync(join(testDir, "test.txt"), "file content");
|
|
101
|
+
const result = await execShellCommand("cat test.txt", testDir);
|
|
102
|
+
expect(result.stdout.trim()).toBe("file content");
|
|
103
|
+
expect(result.exitCode).toBe(0);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("command with arguments", () => {
|
|
108
|
+
test("handles multiple arguments", async () => {
|
|
109
|
+
const result = await execShellCommand("echo one two three", testDir);
|
|
110
|
+
expect(result.stdout.trim()).toBe("one two three");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("handles quoted arguments", async () => {
|
|
114
|
+
const result = await execShellCommand('echo "hello world"', testDir);
|
|
115
|
+
expect(result.stdout.trim()).toBe("hello world");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("handles special characters in arguments", async () => {
|
|
119
|
+
const result = await execShellCommand("echo 'test$var'", testDir);
|
|
120
|
+
expect(result.stdout.trim()).toBe("test$var");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("piped commands", () => {
|
|
125
|
+
test("handles simple pipe", async () => {
|
|
126
|
+
const result = await execShellCommand("echo hello | cat", testDir);
|
|
127
|
+
expect(result.stdout.trim()).toBe("hello");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("handles grep pipe", async () => {
|
|
131
|
+
const result = await execShellCommand(
|
|
132
|
+
"echo -e 'line1\\nline2\\nline3' | grep line2",
|
|
133
|
+
testDir,
|
|
134
|
+
);
|
|
135
|
+
expect(result.stdout.trim()).toBe("line2");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("handles wc pipe", async () => {
|
|
139
|
+
const result = await execShellCommand("echo hello | wc -c", testDir);
|
|
140
|
+
expect(Number.parseInt(result.stdout.trim())).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("error handling", () => {
|
|
145
|
+
test("returns non-zero exit code for failed command", async () => {
|
|
146
|
+
const result = await execShellCommand(
|
|
147
|
+
"ls /nonexistent-directory-12345",
|
|
148
|
+
testDir,
|
|
149
|
+
);
|
|
150
|
+
expect(result.exitCode).not.toBe(0);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("captures stderr for errors", async () => {
|
|
154
|
+
const result = await execShellCommand(
|
|
155
|
+
"ls /nonexistent-directory-12345",
|
|
156
|
+
testDir,
|
|
157
|
+
);
|
|
158
|
+
expect(result.stderr.length).toBeGreaterThan(0);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("handles command not found", async () => {
|
|
162
|
+
const result = await execShellCommand("nonexistent-command-xyz", testDir);
|
|
163
|
+
expect(result.exitCode).not.toBe(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("handles syntax errors", async () => {
|
|
167
|
+
const result = await execShellCommand("echo 'unclosed", testDir);
|
|
168
|
+
expect(result.exitCode).not.toBe(0);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("working directory", () => {
|
|
173
|
+
test("executes in specified directory", async () => {
|
|
174
|
+
const result = await execShellCommand("pwd", testDir);
|
|
175
|
+
// macOS resolves /tmp to /private/tmp
|
|
176
|
+
expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("creates file in working directory", async () => {
|
|
180
|
+
await execShellCommand("touch newfile.txt", testDir);
|
|
181
|
+
const lsResult = await execShellCommand("ls newfile.txt", testDir);
|
|
182
|
+
expect(lsResult.exitCode).toBe(0);
|
|
183
|
+
// Clean up
|
|
184
|
+
Bun.spawnSync(["rm", join(testDir, "newfile.txt")]);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("handles relative paths in working directory", async () => {
|
|
188
|
+
writeFileSync(join(testDir, "data.txt"), "content");
|
|
189
|
+
const result = await execShellCommand("cat ./data.txt", testDir);
|
|
190
|
+
expect(result.stdout.trim()).toBe("content");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("output handling", () => {
|
|
195
|
+
test("handles large stdout", async () => {
|
|
196
|
+
const result = await execShellCommand("seq 1 1000", testDir);
|
|
197
|
+
expect(result.stdout.split("\n").length).toBeGreaterThan(100);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("handles multiline output", async () => {
|
|
201
|
+
const result = await execShellCommand(
|
|
202
|
+
"echo -e 'line1\\nline2\\nline3'",
|
|
203
|
+
testDir,
|
|
204
|
+
);
|
|
205
|
+
const lines = result.stdout.trim().split("\n");
|
|
206
|
+
expect(lines).toHaveLength(3);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("handles mixed stdout and stderr", async () => {
|
|
210
|
+
const result = await execShellCommand("echo out; echo err >&2", testDir);
|
|
211
|
+
expect(result.stdout.trim()).toBe("out");
|
|
212
|
+
expect(result.stderr.trim()).toBe("err");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("handles empty output", async () => {
|
|
216
|
+
const result = await execShellCommand("true", testDir);
|
|
217
|
+
expect(result.stdout).toBe("");
|
|
218
|
+
expect(result.exitCode).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("shell features", () => {
|
|
223
|
+
test("handles environment variables", async () => {
|
|
224
|
+
const result = await execShellCommand("echo $HOME", testDir);
|
|
225
|
+
expect(result.stdout.trim().length).toBeGreaterThan(0);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("handles command substitution", async () => {
|
|
229
|
+
const result = await execShellCommand("echo $(pwd)", testDir);
|
|
230
|
+
// macOS resolves /tmp to /private/tmp
|
|
231
|
+
expect(result.stdout.trim()).toMatch(/\/?tmp\/shell-test-/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("handles glob patterns", async () => {
|
|
235
|
+
writeFileSync(join(testDir, "a.txt"), "");
|
|
236
|
+
writeFileSync(join(testDir, "b.txt"), "");
|
|
237
|
+
const result = await execShellCommand("ls *.txt", testDir);
|
|
238
|
+
expect(result.stdout).toContain("a.txt");
|
|
239
|
+
expect(result.stdout).toContain("b.txt");
|
|
240
|
+
// Clean up
|
|
241
|
+
Bun.spawnSync(["rm", join(testDir, "a.txt"), join(testDir, "b.txt")]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("handles redirections", async () => {
|
|
245
|
+
await execShellCommand("echo content > output.txt", testDir);
|
|
246
|
+
const result = await execShellCommand("cat output.txt", testDir);
|
|
247
|
+
expect(result.stdout.trim()).toBe("content");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("handles && chaining", async () => {
|
|
251
|
+
const result = await execShellCommand(
|
|
252
|
+
"echo first && echo second",
|
|
253
|
+
testDir,
|
|
254
|
+
);
|
|
255
|
+
expect(result.stdout).toContain("first");
|
|
256
|
+
expect(result.stdout).toContain("second");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("handles || chaining", async () => {
|
|
260
|
+
const result = await execShellCommand("false || echo fallback", testDir);
|
|
261
|
+
expect(result.stdout.trim()).toBe("fallback");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("Shell command prefix detection", () => {
|
|
267
|
+
test("detects ! prefix", () => {
|
|
268
|
+
const message = "!ls -la";
|
|
269
|
+
expect(message.startsWith("!")).toBe(true);
|
|
270
|
+
expect(message.slice(1).trim()).toBe("ls -la");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("extracts command after prefix", () => {
|
|
274
|
+
const testCases = [
|
|
275
|
+
{ input: "!pwd", expected: "pwd" },
|
|
276
|
+
{ input: "! pwd", expected: "pwd" },
|
|
277
|
+
{ input: "! pwd", expected: "pwd" },
|
|
278
|
+
{ input: "!ls -la /tmp", expected: "ls -la /tmp" },
|
|
279
|
+
{ input: "!echo hello world", expected: "echo hello world" },
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const { input, expected } of testCases) {
|
|
283
|
+
expect(input.slice(1).trim()).toBe(expected);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("handles empty command after prefix", () => {
|
|
288
|
+
const message = "!";
|
|
289
|
+
const command = message.slice(1).trim();
|
|
290
|
+
expect(command).toBe("");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("handles whitespace only after prefix", () => {
|
|
294
|
+
const message = "! ";
|
|
295
|
+
const command = message.slice(1).trim();
|
|
296
|
+
expect(command).toBe("");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("does not confuse with exclamation in text", () => {
|
|
300
|
+
const notCommands = ["Hello! How are you?", "This is great!", "! at end"];
|
|
301
|
+
|
|
302
|
+
for (const text of notCommands) {
|
|
303
|
+
// These start with ! should still be detected
|
|
304
|
+
// But "Hello!" doesn't start with !
|
|
305
|
+
if (!text.startsWith("!")) {
|
|
306
|
+
expect(text.startsWith("!")).toBe(false);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
});
|
package/src/bookmarks.ts
CHANGED
|
@@ -99,8 +99,12 @@ export function isBookmarked(path: string): boolean {
|
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
101
|
* Resolve path with ~ expansion.
|
|
102
|
+
* If baseDir is provided, relative paths are resolved from there.
|
|
102
103
|
*/
|
|
103
|
-
export function resolvePath(path: string): string {
|
|
104
|
+
export function resolvePath(path: string, baseDir?: string): string {
|
|
104
105
|
const expanded = path.replace(/^~/, homedir());
|
|
106
|
+
if (baseDir && !expanded.startsWith("/")) {
|
|
107
|
+
return resolve(baseDir, expanded);
|
|
108
|
+
}
|
|
105
109
|
return resolve(expanded);
|
|
106
110
|
}
|
package/src/bot.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
handleDocument,
|
|
22
22
|
handleNew,
|
|
23
23
|
handlePhoto,
|
|
24
|
+
handlePreview,
|
|
24
25
|
handleRestart,
|
|
25
26
|
handleResume,
|
|
26
27
|
handleRetry,
|
|
@@ -65,6 +66,7 @@ bot.command("resume", handleResume);
|
|
|
65
66
|
bot.command("restart", handleRestart);
|
|
66
67
|
bot.command("retry", handleRetry);
|
|
67
68
|
bot.command("cd", handleCd);
|
|
69
|
+
bot.command("preview", handlePreview);
|
|
68
70
|
bot.command("bookmarks", handleBookmarks);
|
|
69
71
|
|
|
70
72
|
// ============== Message Handlers ==============
|
|
@@ -104,6 +106,21 @@ console.log("Starting bot...");
|
|
|
104
106
|
const botInfo = await bot.api.getMe();
|
|
105
107
|
console.log(`Bot started: @${botInfo.username}`);
|
|
106
108
|
|
|
109
|
+
// Set up Telegram menu commands
|
|
110
|
+
await bot.api.setMyCommands([
|
|
111
|
+
{ command: "start", description: "Show status and user ID" },
|
|
112
|
+
{ command: "new", description: "Start a fresh session" },
|
|
113
|
+
{ command: "resume", description: "Resume last session" },
|
|
114
|
+
{ command: "stop", description: "Interrupt current query" },
|
|
115
|
+
{ command: "status", description: "Check what Claude is doing" },
|
|
116
|
+
{ command: "cd", description: "Change working directory" },
|
|
117
|
+
{ command: "preview", description: "Download a file" },
|
|
118
|
+
{ command: "bookmarks", description: "Manage directory bookmarks" },
|
|
119
|
+
{ command: "retry", description: "Retry last message" },
|
|
120
|
+
{ command: "restart", description: "Restart the bot" },
|
|
121
|
+
]);
|
|
122
|
+
console.log("Menu commands registered");
|
|
123
|
+
|
|
107
124
|
// Check for pending restart message to update
|
|
108
125
|
if (existsSync(RESTART_FILE)) {
|
|
109
126
|
try {
|