ctb 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -38,12 +38,15 @@ To achieve this, I set up a folder with a CLAUDE.md that teaches Claude about me
38
38
  # Install globally
39
39
  npm install -g ctb
40
40
 
41
+ # Show setup tutorial
42
+ ctb tut
43
+
41
44
  # Run in any project directory
42
45
  cd ~/my-project
43
46
  ctb
44
47
  ```
45
48
 
46
- On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`.
49
+ On first run, `ctb` will prompt for your Telegram bot token and allowed user IDs, then optionally save them to `.env`. Run `ctb tut` for a step-by-step setup guide.
47
50
 
48
51
  **Run multiple instances:** Each project directory gets its own isolated bot session. Open multiple terminals and run `ctb` in different directories.
49
52
 
@@ -102,8 +105,17 @@ new - Start a fresh session
102
105
  resume - Resume last session
103
106
  stop - Interrupt current query
104
107
  status - Check what Claude is doing
108
+ model - Switch model (sonnet, opus, haiku)
109
+ cost - Show token usage and estimated cost
110
+ think - Force thinking mode
111
+ plan - Toggle planning mode
112
+ compact - Trigger context compaction
113
+ undo - Revert file changes to last checkpoint
105
114
  cd - Change working directory
115
+ skill - Invoke a Claude Code skill
116
+ file - Download a file
106
117
  bookmarks - Manage directory bookmarks
118
+ retry - Retry last message
107
119
  restart - Restart the bot
108
120
  ```
109
121
 
@@ -148,16 +160,35 @@ The bot includes a built-in `ask_user` MCP server that lets Claude present optio
148
160
 
149
161
  ## Bot Commands
150
162
 
151
- | Command | Description |
152
- | ------------ | --------------------------------- |
153
- | `/start` | Show status and your user ID |
154
- | `/new` | Start a fresh session |
155
- | `/resume` | Resume last session after restart |
156
- | `/stop` | Interrupt current query |
157
- | `/status` | Check what Claude is doing |
158
- | `/cd <path>` | Change working directory |
159
- | `/bookmarks` | Manage directory bookmarks |
160
- | `/restart` | Restart the bot |
163
+ | Command | Description |
164
+ | --------------- | ---------------------------------------------------------- |
165
+ | `/start` | Show status and your user ID |
166
+ | `/new` | Start a fresh session |
167
+ | `/resume` | Resume last session after restart |
168
+ | `/stop` | Interrupt current query (aliases: `/c`, `/kill`, `/dc`) |
169
+ | `/status` | Check what Claude is doing |
170
+ | `/model <name>` | Switch model: sonnet, opus, haiku |
171
+ | `/cost` | Show token usage and estimated cost |
172
+ | `/think [lvl]` | Force thinking: off, normal, deep (default) |
173
+ | `/plan` | Toggle planning mode (no tool execution) |
174
+ | `/compact` | Trigger context compaction |
175
+ | `/undo` | Revert file changes to last checkpoint |
176
+ | `/cd <path>` | Change working directory |
177
+ | `/skill <name>` | Invoke a Claude Code skill (e.g., `/skill commit`) |
178
+ | `/file [path]` | Download file (auto-detects from last response if no path) |
179
+ | `/bookmarks` | Manage directory bookmarks |
180
+ | `/retry` | Retry last message |
181
+ | `/restart` | Restart the bot |
182
+
183
+ ### Shell Commands
184
+
185
+ Prefix any message with `!` to run it as a shell command in the working directory:
186
+
187
+ ```
188
+ !ls -la
189
+ !git status
190
+ !pwd
191
+ ```
161
192
 
162
193
  ### Directory Navigation
163
194
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctb",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Control Claude Code from Telegram - run multiple bot instances per project",
5
5
  "type": "module",
6
6
  "bin": {
@@ -180,6 +180,7 @@ describe("CLI argument parsing", () => {
180
180
  dir?: string;
181
181
  help?: boolean;
182
182
  version?: boolean;
183
+ tut?: boolean;
183
184
  } {
184
185
  const options: {
185
186
  token?: string;
@@ -187,6 +188,7 @@ describe("CLI argument parsing", () => {
187
188
  dir?: string;
188
189
  help?: boolean;
189
190
  version?: boolean;
191
+ tut?: boolean;
190
192
  } = {};
191
193
 
192
194
  for (const arg of args) {
@@ -194,6 +196,8 @@ describe("CLI argument parsing", () => {
194
196
  options.help = true;
195
197
  } else if (arg === "--version" || arg === "-v") {
196
198
  options.version = true;
199
+ } else if (arg === "tut" || arg === "tutorial") {
200
+ options.tut = true;
197
201
  } else if (arg.startsWith("--token=")) {
198
202
  options.token = arg.slice(8);
199
203
  } else if (arg.startsWith("--users=")) {
@@ -222,6 +226,14 @@ describe("CLI argument parsing", () => {
222
226
  expect(parseArgs(["-v"])).toEqual({ version: true });
223
227
  });
224
228
 
229
+ test("parses tut command", () => {
230
+ expect(parseArgs(["tut"])).toEqual({ tut: true });
231
+ });
232
+
233
+ test("parses tutorial command", () => {
234
+ expect(parseArgs(["tutorial"])).toEqual({ tut: true });
235
+ });
236
+
225
237
  test("parses --token option", () => {
226
238
  const result = parseArgs(["--token=my-bot-token"]);
227
239
  expect(result.token).toBe("my-bot-token");
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Unit tests for session module - model, cost, thinking, and plan mode features.
3
+ */
4
+
5
+ import { beforeEach, describe, expect, test } from "bun:test";
6
+ import { ClaudeSession, getThinkingLevel } from "../session";
7
+
8
+ describe("getThinkingLevel", () => {
9
+ // Default keywords from config.ts:
10
+ // THINKING_KEYWORDS: "think,pensa,ragiona"
11
+ // THINKING_DEEP_KEYWORDS: "ultrathink,think hard,pensa bene"
12
+
13
+ describe("deep thinking triggers", () => {
14
+ test("triggers on 'think hard'", () => {
15
+ expect(getThinkingLevel("Please think hard about this")).toBe(50000);
16
+ });
17
+
18
+ test("triggers on 'ultrathink'", () => {
19
+ expect(getThinkingLevel("ultrathink about the problem")).toBe(50000);
20
+ });
21
+
22
+ test("triggers on 'pensa bene' (Italian)", () => {
23
+ expect(getThinkingLevel("pensa bene prima di rispondere")).toBe(50000);
24
+ });
25
+
26
+ test("is case insensitive", () => {
27
+ expect(getThinkingLevel("THINK HARD about this")).toBe(50000);
28
+ expect(getThinkingLevel("ULTRATHINK")).toBe(50000);
29
+ });
30
+ });
31
+
32
+ describe("normal thinking triggers", () => {
33
+ test("triggers on 'think'", () => {
34
+ expect(getThinkingLevel("Let me think about this")).toBe(10000);
35
+ });
36
+
37
+ test("triggers on 'pensa' (Italian)", () => {
38
+ expect(getThinkingLevel("pensa a questo problema")).toBe(10000);
39
+ });
40
+
41
+ test("triggers on 'ragiona' (Italian)", () => {
42
+ expect(getThinkingLevel("ragiona sul codice")).toBe(10000);
43
+ });
44
+
45
+ test("is case insensitive", () => {
46
+ expect(getThinkingLevel("THINK about it")).toBe(10000);
47
+ expect(getThinkingLevel("PENSA attentamente")).toBe(10000);
48
+ });
49
+ });
50
+
51
+ describe("no thinking triggers", () => {
52
+ test("returns 0 for simple messages", () => {
53
+ expect(getThinkingLevel("Hello")).toBe(0);
54
+ expect(getThinkingLevel("What time is it?")).toBe(0);
55
+ expect(getThinkingLevel("Show me the file")).toBe(0);
56
+ });
57
+
58
+ test("returns 0 for empty string", () => {
59
+ expect(getThinkingLevel("")).toBe(0);
60
+ });
61
+
62
+ test("returns 0 for unrelated words", () => {
63
+ expect(getThinkingLevel("analyze this code")).toBe(0);
64
+ expect(getThinkingLevel("consider the options")).toBe(0);
65
+ expect(getThinkingLevel("evaluate this approach")).toBe(0);
66
+ });
67
+ });
68
+
69
+ describe("priority", () => {
70
+ test("deep triggers take precedence over normal", () => {
71
+ // "think hard" should trigger deep (50000), not normal "think" (10000)
72
+ expect(getThinkingLevel("think hard")).toBe(50000);
73
+ });
74
+ });
75
+ });
76
+
77
+ describe("ClaudeSession", () => {
78
+ let session: ClaudeSession;
79
+
80
+ beforeEach(() => {
81
+ session = new ClaudeSession();
82
+ });
83
+
84
+ describe("model selection", () => {
85
+ test("defaults to sonnet", () => {
86
+ expect(session.currentModel).toBe("sonnet");
87
+ });
88
+
89
+ test("can be set to opus", () => {
90
+ session.currentModel = "opus";
91
+ expect(session.currentModel).toBe("opus");
92
+ });
93
+
94
+ test("can be set to haiku", () => {
95
+ session.currentModel = "haiku";
96
+ expect(session.currentModel).toBe("haiku");
97
+ });
98
+
99
+ test("can switch between models", () => {
100
+ session.currentModel = "opus";
101
+ expect(session.currentModel).toBe("opus");
102
+
103
+ session.currentModel = "haiku";
104
+ expect(session.currentModel).toBe("haiku");
105
+
106
+ session.currentModel = "sonnet";
107
+ expect(session.currentModel).toBe("sonnet");
108
+ });
109
+ });
110
+
111
+ describe("forceThinking", () => {
112
+ test("defaults to null", () => {
113
+ expect(session.forceThinking).toBeNull();
114
+ });
115
+
116
+ test("can be set to specific token count", () => {
117
+ session.forceThinking = 10000;
118
+ expect(session.forceThinking).toBe(10000);
119
+ });
120
+
121
+ test("can be set to 0 (off)", () => {
122
+ session.forceThinking = 0;
123
+ expect(session.forceThinking).toBe(0);
124
+ });
125
+
126
+ test("can be set to deep (50000)", () => {
127
+ session.forceThinking = 50000;
128
+ expect(session.forceThinking).toBe(50000);
129
+ });
130
+ });
131
+
132
+ describe("planMode", () => {
133
+ test("defaults to false", () => {
134
+ expect(session.planMode).toBe(false);
135
+ });
136
+
137
+ test("can be toggled on", () => {
138
+ session.planMode = true;
139
+ expect(session.planMode).toBe(true);
140
+ });
141
+
142
+ test("can be toggled off", () => {
143
+ session.planMode = true;
144
+ session.planMode = false;
145
+ expect(session.planMode).toBe(false);
146
+ });
147
+ });
148
+
149
+ describe("token usage tracking", () => {
150
+ test("starts with zero totals", () => {
151
+ expect(session.totalInputTokens).toBe(0);
152
+ expect(session.totalOutputTokens).toBe(0);
153
+ expect(session.totalCacheReadTokens).toBe(0);
154
+ });
155
+
156
+ test("can accumulate input tokens", () => {
157
+ session.totalInputTokens += 1000;
158
+ session.totalInputTokens += 500;
159
+ expect(session.totalInputTokens).toBe(1500);
160
+ });
161
+
162
+ test("can accumulate output tokens", () => {
163
+ session.totalOutputTokens += 2000;
164
+ session.totalOutputTokens += 800;
165
+ expect(session.totalOutputTokens).toBe(2800);
166
+ });
167
+
168
+ test("can accumulate cache read tokens", () => {
169
+ session.totalCacheReadTokens += 500;
170
+ session.totalCacheReadTokens += 300;
171
+ expect(session.totalCacheReadTokens).toBe(800);
172
+ });
173
+ });
174
+
175
+ describe("estimateCost", () => {
176
+ describe("sonnet pricing", () => {
177
+ test("calculates zero cost for zero tokens", () => {
178
+ const cost = session.estimateCost();
179
+ expect(cost.inputCost).toBe(0);
180
+ expect(cost.outputCost).toBe(0);
181
+ expect(cost.total).toBe(0);
182
+ });
183
+
184
+ test("calculates cost for 1M input tokens", () => {
185
+ session.totalInputTokens = 1_000_000;
186
+ const cost = session.estimateCost();
187
+ expect(cost.inputCost).toBe(3); // $3 per 1M input
188
+ expect(cost.outputCost).toBe(0);
189
+ expect(cost.total).toBe(3);
190
+ });
191
+
192
+ test("calculates cost for 1M output tokens", () => {
193
+ session.totalOutputTokens = 1_000_000;
194
+ const cost = session.estimateCost();
195
+ expect(cost.inputCost).toBe(0);
196
+ expect(cost.outputCost).toBe(15); // $15 per 1M output
197
+ expect(cost.total).toBe(15);
198
+ });
199
+
200
+ test("calculates combined cost", () => {
201
+ session.totalInputTokens = 500_000;
202
+ session.totalOutputTokens = 100_000;
203
+ const cost = session.estimateCost();
204
+ expect(cost.inputCost).toBe(1.5); // $3 * 0.5
205
+ expect(cost.outputCost).toBe(1.5); // $15 * 0.1
206
+ expect(cost.total).toBe(3);
207
+ });
208
+ });
209
+
210
+ describe("opus pricing", () => {
211
+ beforeEach(() => {
212
+ session.currentModel = "opus";
213
+ });
214
+
215
+ test("calculates cost for 1M input tokens", () => {
216
+ session.totalInputTokens = 1_000_000;
217
+ const cost = session.estimateCost();
218
+ expect(cost.inputCost).toBe(15); // $15 per 1M input
219
+ });
220
+
221
+ test("calculates cost for 1M output tokens", () => {
222
+ session.totalOutputTokens = 1_000_000;
223
+ const cost = session.estimateCost();
224
+ expect(cost.outputCost).toBe(75); // $75 per 1M output
225
+ });
226
+
227
+ test("calculates combined cost", () => {
228
+ session.totalInputTokens = 100_000;
229
+ session.totalOutputTokens = 50_000;
230
+ const cost = session.estimateCost();
231
+ expect(cost.inputCost).toBe(1.5); // $15 * 0.1
232
+ expect(cost.outputCost).toBe(3.75); // $75 * 0.05
233
+ expect(cost.total).toBe(5.25);
234
+ });
235
+ });
236
+
237
+ describe("haiku pricing", () => {
238
+ beforeEach(() => {
239
+ session.currentModel = "haiku";
240
+ });
241
+
242
+ test("calculates cost for 1M input tokens", () => {
243
+ session.totalInputTokens = 1_000_000;
244
+ const cost = session.estimateCost();
245
+ expect(cost.inputCost).toBe(0.25); // $0.25 per 1M input
246
+ });
247
+
248
+ test("calculates cost for 1M output tokens", () => {
249
+ session.totalOutputTokens = 1_000_000;
250
+ const cost = session.estimateCost();
251
+ expect(cost.outputCost).toBe(1.25); // $1.25 per 1M output
252
+ });
253
+
254
+ test("calculates combined cost", () => {
255
+ session.totalInputTokens = 4_000_000;
256
+ session.totalOutputTokens = 800_000;
257
+ const cost = session.estimateCost();
258
+ expect(cost.inputCost).toBe(1); // $0.25 * 4
259
+ expect(cost.outputCost).toBe(1); // $1.25 * 0.8
260
+ expect(cost.total).toBe(2);
261
+ });
262
+ });
263
+
264
+ describe("small token counts", () => {
265
+ test("calculates fractional costs", () => {
266
+ session.totalInputTokens = 1000;
267
+ session.totalOutputTokens = 500;
268
+ const cost = session.estimateCost();
269
+ expect(cost.inputCost).toBeCloseTo(0.003, 5); // $3 * 0.001
270
+ expect(cost.outputCost).toBeCloseTo(0.0075, 5); // $15 * 0.0005
271
+ expect(cost.total).toBeCloseTo(0.0105, 5);
272
+ });
273
+ });
274
+ });
275
+
276
+ describe("kill", () => {
277
+ test("resets sessionId", async () => {
278
+ session.sessionId = "test-session-123";
279
+ await session.kill();
280
+ expect(session.sessionId).toBeNull();
281
+ });
282
+
283
+ test("resets lastActivity", async () => {
284
+ session.lastActivity = new Date();
285
+ await session.kill();
286
+ expect(session.lastActivity).toBeNull();
287
+ });
288
+
289
+ test("resets token totals", async () => {
290
+ session.totalInputTokens = 10000;
291
+ session.totalOutputTokens = 5000;
292
+ session.totalCacheReadTokens = 2000;
293
+ await session.kill();
294
+ expect(session.totalInputTokens).toBe(0);
295
+ expect(session.totalOutputTokens).toBe(0);
296
+ expect(session.totalCacheReadTokens).toBe(0);
297
+ });
298
+
299
+ test("resets planMode", async () => {
300
+ session.planMode = true;
301
+ await session.kill();
302
+ expect(session.planMode).toBe(false);
303
+ });
304
+
305
+ test("resets forceThinking", async () => {
306
+ session.forceThinking = 50000;
307
+ await session.kill();
308
+ expect(session.forceThinking).toBeNull();
309
+ });
310
+
311
+ test("preserves currentModel", async () => {
312
+ session.currentModel = "opus";
313
+ await session.kill();
314
+ expect(session.currentModel).toBe("opus");
315
+ });
316
+ });
317
+
318
+ describe("isActive", () => {
319
+ test("returns false when no sessionId", () => {
320
+ expect(session.isActive).toBe(false);
321
+ });
322
+
323
+ test("returns true when sessionId exists", () => {
324
+ session.sessionId = "test-session-123";
325
+ expect(session.isActive).toBe(true);
326
+ });
327
+ });
328
+
329
+ describe("workingDir", () => {
330
+ test("can get working directory", () => {
331
+ expect(typeof session.workingDir).toBe("string");
332
+ });
333
+
334
+ test("setWorkingDir changes directory", () => {
335
+ const newDir = "/tmp/test-dir";
336
+ session.setWorkingDir(newDir);
337
+ expect(session.workingDir).toBe(newDir);
338
+ });
339
+
340
+ test("setWorkingDir clears sessionId", () => {
341
+ session.sessionId = "test-session-123";
342
+ session.setWorkingDir("/tmp/new-dir");
343
+ expect(session.sessionId).toBeNull();
344
+ });
345
+ });
346
+
347
+ describe("stop", () => {
348
+ test("returns false when nothing is running", async () => {
349
+ const result = await session.stop();
350
+ expect(result).toBe(false);
351
+ });
352
+ });
353
+
354
+ describe("interrupt flags", () => {
355
+ test("markInterrupt and consumeInterruptFlag work together", () => {
356
+ expect(session.consumeInterruptFlag()).toBe(false);
357
+ session.markInterrupt();
358
+ expect(session.consumeInterruptFlag()).toBe(true);
359
+ expect(session.consumeInterruptFlag()).toBe(false); // Consumed
360
+ });
361
+ });
362
+
363
+ describe("processing state", () => {
364
+ test("startProcessing returns cleanup function", () => {
365
+ expect(session.isRunning).toBe(false);
366
+ const cleanup = session.startProcessing();
367
+ expect(session.isRunning).toBe(true);
368
+ cleanup();
369
+ expect(session.isRunning).toBe(false);
370
+ });
371
+ });
372
+
373
+ describe("undo/checkpointing", () => {
374
+ test("canUndo is false by default", () => {
375
+ expect(session.canUndo).toBe(false);
376
+ });
377
+
378
+ test("undoCheckpoints is 0 by default", () => {
379
+ expect(session.undoCheckpoints).toBe(0);
380
+ });
381
+
382
+ test("undo fails when no session active", async () => {
383
+ const [success, message] = await session.undo();
384
+ expect(success).toBe(false);
385
+ expect(message).toContain("No active session");
386
+ });
387
+
388
+ test("kill resets checkpoints", async () => {
389
+ // Simulate having checkpoints (by accessing private property for testing)
390
+ // @ts-expect-error - accessing private for test
391
+ session._userMessageUuids = ["uuid1", "uuid2"];
392
+ expect(session.undoCheckpoints).toBe(2);
393
+
394
+ await session.kill();
395
+ expect(session.undoCheckpoints).toBe(0);
396
+ expect(session.canUndo).toBe(false);
397
+ });
398
+ });
399
+ });
package/src/bot.ts CHANGED
@@ -18,17 +18,24 @@ import {
18
18
  handleBookmarks,
19
19
  handleCallback,
20
20
  handleCd,
21
+ handleCompact,
22
+ handleCost,
21
23
  handleDocument,
24
+ handleFile,
25
+ handleModel,
22
26
  handleNew,
23
27
  handlePhoto,
24
- handlePreview,
28
+ handlePlan,
25
29
  handleRestart,
26
30
  handleResume,
27
31
  handleRetry,
32
+ handleSkill,
28
33
  handleStart,
29
34
  handleStatus,
30
35
  handleStop,
31
36
  handleText,
37
+ handleThink,
38
+ handleUndo,
32
39
  handleVoice,
33
40
  } from "./handlers";
34
41
 
@@ -61,12 +68,22 @@ bot.use(
61
68
  bot.command("start", handleStart);
62
69
  bot.command("new", handleNew);
63
70
  bot.command("stop", handleStop);
71
+ bot.command("c", handleStop);
72
+ bot.command("kill", handleStop);
73
+ bot.command("dc", handleStop);
64
74
  bot.command("status", handleStatus);
65
75
  bot.command("resume", handleResume);
66
76
  bot.command("restart", handleRestart);
67
77
  bot.command("retry", handleRetry);
68
78
  bot.command("cd", handleCd);
69
- bot.command("preview", handlePreview);
79
+ bot.command("skill", handleSkill);
80
+ bot.command("file", handleFile);
81
+ bot.command("model", handleModel);
82
+ bot.command("cost", handleCost);
83
+ bot.command("think", handleThink);
84
+ bot.command("plan", handlePlan);
85
+ bot.command("compact", handleCompact);
86
+ bot.command("undo", handleUndo);
70
87
  bot.command("bookmarks", handleBookmarks);
71
88
 
72
89
  // ============== Message Handlers ==============
@@ -113,8 +130,15 @@ await bot.api.setMyCommands([
113
130
  { command: "resume", description: "Resume last session" },
114
131
  { command: "stop", description: "Interrupt current query" },
115
132
  { command: "status", description: "Check what Claude is doing" },
133
+ { command: "model", description: "Switch model (sonnet/opus/haiku)" },
134
+ { command: "cost", description: "Show token usage and cost" },
135
+ { command: "think", description: "Force extended thinking" },
136
+ { command: "plan", description: "Toggle planning mode" },
137
+ { command: "compact", description: "Trigger context compaction" },
138
+ { command: "undo", description: "Revert file changes" },
116
139
  { command: "cd", description: "Change working directory" },
117
- { command: "preview", description: "Download a file" },
140
+ { command: "skill", description: "Invoke a Claude Code skill" },
141
+ { command: "file", description: "Download a file" },
118
142
  { command: "bookmarks", description: "Manage directory bookmarks" },
119
143
  { command: "retry", description: "Retry last message" },
120
144
  { command: "restart", description: "Restart the bot" },