macroclaw 0.12.0 → 0.13.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/src/setup.test.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
- import type { SetupIO } from "./setup";
2
+ import type { SetupIo } from "./setup";
3
3
 
4
4
  // Mock child_process so resolveClaudePath doesn't hit real `which`
5
+ const mockExecSync = mock((_cmd: string, _opts?: object) => "/mock/bin/claude\n");
5
6
  mock.module("node:child_process", () => ({
6
- execSync: (_cmd: string) => "/mock/bin/claude\n",
7
+ execSync: mockExecSync,
7
8
  }));
8
9
 
9
10
  // Mock Grammy Bot
10
11
  const mockBotInit = mock(async () => {});
11
- const mockBotStart = mock(() => {});
12
+ const mockBotStart = mock(async () => {});
12
13
  const mockBotStop = mock(async () => {});
13
14
  const mockSetMyCommands = mock(async () => {});
14
15
  let mockBotCatchHandler: Function | null = null;
@@ -37,8 +38,8 @@ mock.module("grammy", () => ({
37
38
  await mockBotInit();
38
39
  }
39
40
 
40
- start() {
41
- mockBotStart();
41
+ async start() {
42
+ await mockBotStart();
42
43
  }
43
44
 
44
45
  async stop() {
@@ -55,23 +56,30 @@ function createMockServiceInstaller() {
55
56
  };
56
57
  }
57
58
 
58
- const { resolveClaudePath, runSetupWizard } = await import("./setup");
59
+ const { SetupWizard } = await import("./setup");
59
60
 
60
- function createMockIO(inputs: string[]): SetupIO & { written: string[]; closed: boolean } {
61
+ async function runSetup(io: SetupIo, opts?: ConstructorParameters<typeof SetupWizard>[1]) {
62
+ const wizard = new SetupWizard(io, opts);
63
+ const settings = await wizard.collectSettings();
64
+ await wizard.installService();
65
+ return settings;
66
+ }
67
+
68
+ function createMockIO(inputs: string[]): SetupIo & { written: string[] } {
61
69
  let index = 0;
62
70
  const written: string[] = [];
63
71
  const io = {
72
+ open: () => {},
73
+ close: () => {},
64
74
  ask: async () => inputs[index++] ?? "",
65
75
  write: (msg: string) => { written.push(msg); },
66
- close: () => { io.closed = true; },
67
76
  written,
68
- closed: false,
69
77
  };
70
78
  return io;
71
79
  }
72
80
 
73
81
  // Save/restore env vars
74
- const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "OPENAI_API_KEY"];
82
+ const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL"];
75
83
  const savedEnv: Record<string, string | undefined> = {};
76
84
 
77
85
  beforeEach(() => {
@@ -79,6 +87,7 @@ beforeEach(() => {
79
87
  savedEnv[v] = process.env[v];
80
88
  delete process.env[v];
81
89
  }
90
+ mockExecSync.mockImplementation((_cmd: string, _opts?: object) => "/mock/bin/claude\n");
82
91
  mockBotInit.mockImplementation(async () => {});
83
92
  mockBotStart.mockClear();
84
93
  mockBotStop.mockClear();
@@ -95,7 +104,7 @@ afterEach(() => {
95
104
  }
96
105
  });
97
106
 
98
- describe("runSetupWizard", () => {
107
+ describe("SetupWizard", () => {
99
108
  it("collects all required fields via prompts", async () => {
100
109
  const io = createMockIO([
101
110
  "123:ABC", // bot token
@@ -106,19 +115,19 @@ describe("runSetupWizard", () => {
106
115
  "", // no service install
107
116
  ]);
108
117
 
109
- const settings = await runSetupWizard(io);
118
+ const settings = await runSetup(io);
110
119
 
111
120
  expect(settings.botToken).toBe("123:ABC");
112
121
  expect(settings.chatId).toBe("12345678");
113
122
  expect(settings.model).toBe("opus");
114
123
  expect(settings.workspace).toBe("/my/ws");
115
124
  expect(settings.openaiApiKey).toBe("sk-test");
116
- expect(settings.logLevel).toBe("debug");
125
+ expect(settings.logLevel).toBe("info");
117
126
  });
118
127
 
119
128
  it("uses defaults from env vars when user presses enter", async () => {
120
129
  process.env.TELEGRAM_BOT_TOKEN = "env-token";
121
- process.env.AUTHORIZED_CHAT_ID = "env-chat";
130
+ process.env.AUTHORIZED_CHAT_ID = "99887766";
122
131
  process.env.MODEL = "haiku";
123
132
  process.env.WORKSPACE = "/env/ws";
124
133
  process.env.OPENAI_API_KEY = "sk-env";
@@ -132,10 +141,10 @@ describe("runSetupWizard", () => {
132
141
  "", // no service install
133
142
  ]);
134
143
 
135
- const settings = await runSetupWizard(io);
144
+ const settings = await runSetup(io);
136
145
 
137
146
  expect(settings.botToken).toBe("env-token");
138
- expect(settings.chatId).toBe("env-chat");
147
+ expect(settings.chatId).toBe("99887766");
139
148
  expect(settings.model).toBe("haiku");
140
149
  expect(settings.workspace).toBe("/env/ws");
141
150
  expect(settings.openaiApiKey).toBe("sk-env");
@@ -151,7 +160,7 @@ describe("runSetupWizard", () => {
151
160
  "", // no service install
152
161
  ]);
153
162
 
154
- const settings = await runSetupWizard(io);
163
+ const settings = await runSetup(io);
155
164
 
156
165
  expect(settings.model).toBe("sonnet");
157
166
  expect(settings.workspace).toBe("~/.macroclaw-workspace");
@@ -168,7 +177,7 @@ describe("runSetupWizard", () => {
168
177
  "", // no service install
169
178
  ]);
170
179
 
171
- await runSetupWizard(io);
180
+ await runSetup(io);
172
181
 
173
182
  expect(mockBotInit).toHaveBeenCalled();
174
183
  expect(mockBotStart).toHaveBeenCalled();
@@ -192,7 +201,7 @@ describe("runSetupWizard", () => {
192
201
  "", // no service install
193
202
  ]);
194
203
 
195
- const settings = await runSetupWizard(io);
204
+ const settings = await runSetup(io);
196
205
 
197
206
  expect(settings.botToken).toBe("good-token");
198
207
  expect(callCount).toBe(2);
@@ -209,7 +218,7 @@ describe("runSetupWizard", () => {
209
218
  "", // no service install
210
219
  ]);
211
220
 
212
- const settings = await runSetupWizard(io);
221
+ const settings = await runSetup(io);
213
222
 
214
223
  expect(settings.botToken).toBe("actual-token");
215
224
  });
@@ -225,85 +234,82 @@ describe("runSetupWizard", () => {
225
234
  "", // no service install
226
235
  ]);
227
236
 
228
- const settings = await runSetupWizard(io);
237
+ const settings = await runSetup(io);
229
238
 
230
239
  expect(settings.chatId).toBe("456");
231
240
  });
232
241
 
233
- it("registers and uses catch handler on setup bot", async () => {
242
+ it("re-prompts when chat ID is not numeric", async () => {
234
243
  const io = createMockIO([
235
244
  "tok",
236
- "123",
245
+ "not-a-number", // invalid — re-prompt
246
+ "456",
237
247
  "",
238
248
  "",
239
249
  "",
240
250
  "", // no service install
241
251
  ]);
242
252
 
243
- await runSetupWizard(io);
253
+ const settings = await runSetup(io);
244
254
 
245
- // The catch handler was registered
246
- expect(mockBotCatchHandler).not.toBeNull();
247
- // Calling it should not throw (it logs internally)
248
- expect(() => mockBotCatchHandler!(new Error("test error"))).not.toThrow();
255
+ expect(settings.chatId).toBe("456");
256
+ expect(io.written).toContainEqual(expect.stringContaining("Invalid value"));
249
257
  });
250
258
 
251
- it("registers /chatid command handler on setup bot", async () => {
259
+ it("re-prompts when model is invalid", async () => {
252
260
  const io = createMockIO([
253
261
  "tok",
254
262
  "123",
255
- "",
263
+ "xxx", // invalid — re-prompt
264
+ "opus", // valid
256
265
  "",
257
266
  "",
258
267
  "", // no service install
259
268
  ]);
260
269
 
261
- await runSetupWizard(io);
270
+ const settings = await runSetup(io);
262
271
 
263
- expect(mockBotCommandHandler).not.toBeNull();
264
- const mockReply = mock(() => {});
265
- mockBotCommandHandler!({ chat: { id: 12345 }, reply: mockReply });
266
- expect(mockReply).toHaveBeenCalledWith("12345");
272
+ expect(settings.model).toBe("opus");
273
+ expect(io.written).toContainEqual(expect.stringContaining("Invalid value"));
267
274
  });
268
275
 
269
- it("calls onSettingsReady before service install prompt", async () => {
270
- const order: string[] = [];
271
- const installer = { install: () => { order.push("install"); return ""; } };
272
- const onSettingsReady = () => { order.push("save"); };
276
+ it("registers and uses catch handler on setup bot", async () => {
273
277
  const io = createMockIO([
274
278
  "tok",
275
279
  "123",
276
280
  "",
277
281
  "",
278
282
  "",
279
- "y",
280
- "sk-test-token", // oauth token (macOS)
283
+ "", // no service install
281
284
  ]);
282
285
 
283
- await runSetupWizard(io, { serviceInstaller: installer, onSettingsReady, platform: "darwin" });
286
+ await runSetup(io);
284
287
 
285
- expect(order).toEqual(["save", "install"]);
288
+ // The catch handler was registered
289
+ expect(mockBotCatchHandler).not.toBeNull();
290
+ // Calling it should not throw (it logs internally)
291
+ expect(() => mockBotCatchHandler!(new Error("test error"))).not.toThrow();
286
292
  });
287
293
 
288
- it("closes io before running service install", async () => {
289
- let closedBeforeInstall = false;
294
+ it("registers /chatid command handler on setup bot", async () => {
290
295
  const io = createMockIO([
291
296
  "tok",
292
297
  "123",
293
298
  "",
294
299
  "",
295
300
  "",
296
- "y",
297
- "sk-test-token", // oauth token (macOS)
301
+ "", // no service install
298
302
  ]);
299
- const installer = { install: () => { closedBeforeInstall = io.closed; return ""; } };
300
303
 
301
- await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
304
+ await runSetup(io);
302
305
 
303
- expect(closedBeforeInstall).toBe(true);
306
+ expect(mockBotCommandHandler).not.toBeNull();
307
+ const mockReply = mock(() => {});
308
+ mockBotCommandHandler!({ chat: { id: 12345 }, reply: mockReply });
309
+ expect(mockReply).toHaveBeenCalledWith("12345");
304
310
  });
305
311
 
306
- it("installs service when user answers yes", async () => {
312
+ it("installs service when user answers yes", async () => {
307
313
  mockInstall.mockClear();
308
314
  const installer = createMockServiceInstaller();
309
315
  const io = createMockIO([
@@ -316,7 +322,7 @@ describe("runSetupWizard", () => {
316
322
  "sk-test-token", // oauth token (macOS)
317
323
  ]);
318
324
 
319
- await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
325
+ await runSetup(io, { serviceInstaller: installer, platform: "darwin" });
320
326
 
321
327
  expect(mockInstall).toHaveBeenCalled();
322
328
  expect(io.written).toContainEqual(expect.stringContaining("Service installed and started."));
@@ -334,10 +340,9 @@ describe("runSetupWizard", () => {
334
340
  "n", // no to service install
335
341
  ]);
336
342
 
337
- await runSetupWizard(io, { serviceInstaller: installer });
343
+ await runSetup(io, { serviceInstaller: installer });
338
344
 
339
345
  expect(mockInstall).not.toHaveBeenCalled();
340
- expect(io.closed).toBe(true);
341
346
  });
342
347
 
343
348
  it("skips service install when oauth token is empty on macOS", async () => {
@@ -353,7 +358,7 @@ describe("runSetupWizard", () => {
353
358
  "", // empty oauth token
354
359
  ]);
355
360
 
356
- const settings = await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
361
+ const settings = await runSetup(io, { serviceInstaller: installer, platform: "darwin" });
357
362
 
358
363
  expect(mockInstall).not.toHaveBeenCalled();
359
364
  expect(io.written).toContainEqual(expect.stringContaining("No token provided"));
@@ -373,26 +378,17 @@ describe("runSetupWizard", () => {
373
378
  "sk-test-token", // oauth token (macOS)
374
379
  ]);
375
380
 
376
- await runSetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
381
+ await runSetup(io, { serviceInstaller: installer, platform: "darwin" });
377
382
 
378
383
  expect(io.written).toContainEqual(expect.stringContaining("Service installation failed: Permission denied"));
379
384
  });
380
385
 
381
386
  it("fails fast when claude CLI is not found", async () => {
387
+ mockExecSync.mockImplementation(() => { throw new Error("not found"); });
382
388
  const io = createMockIO([]);
383
389
  await expect(
384
- runSetupWizard(io, { resolveClaude: () => { throw new Error("Claude Code CLI not found."); } }),
390
+ runSetup(io),
385
391
  ).rejects.toThrow("Claude Code CLI not found.");
386
392
  });
387
393
 
388
- it("resolveClaudePath returns trimmed path on success", () => {
389
- const result = resolveClaudePath(() => "/usr/local/bin/claude\n");
390
- expect(result).toBe("/usr/local/bin/claude");
391
- });
392
-
393
- it("resolveClaudePath throws when claude is not found", () => {
394
- expect(() => resolveClaudePath(() => { throw new Error("not found"); })).toThrow(
395
- "Claude Code CLI not found.",
396
- );
397
- });
398
394
  });
package/src/setup.ts CHANGED
@@ -1,149 +1,191 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import { Bot } from "grammy";
3
3
  import { createLogger } from "./logger";
4
- import { maskValue, type Settings, settingsSchema } from "./settings";
4
+ import { maskValue, type Settings, SettingsManager, settingsSchema } from "./settings";
5
5
 
6
6
  const log = createLogger("setup");
7
7
 
8
- export interface SetupIO {
8
+ type SetupField = keyof typeof settingsSchema.shape;
9
+
10
+ export interface SetupIo {
11
+ open: () => void;
12
+ close: () => void;
9
13
  ask: (question: string) => Promise<string>;
10
14
  write: (msg: string) => void;
11
- close?: () => void;
12
- }
13
-
14
- async function startSetupBot(token: string): Promise<Bot> {
15
- const bot = new Bot(token);
16
- bot.command("chatid", (ctx) => {
17
- ctx.reply(ctx.chat.id.toString());
18
- });
19
- bot.catch((err) => {
20
- log.debug({ err }, "Setup bot error");
21
- });
22
-
23
- await bot.init();
24
- await bot.api.setMyCommands([{ command: "chatid", description: "Get your chat ID" }]);
25
- bot.start();
26
- return bot;
27
15
  }
28
16
 
29
17
  export interface ServiceInstaller {
30
18
  install: (oauthToken?: string) => string;
31
19
  }
32
20
 
33
- export interface SetupDefaults {
34
- botToken?: string;
35
- chatId?: string;
36
- model?: string;
37
- workspace?: string;
38
- openaiApiKey?: string;
39
- }
40
-
41
- export function resolveClaudePath(exec: (cmd: string) => string = (cmd) => execSync(cmd, { encoding: "utf-8" })): string {
42
- try {
43
- return exec("which claude").trim();
44
- } catch {
45
- throw new Error("Claude Code CLI not found. Install it first: https://docs.anthropic.com/en/docs/claude-code");
21
+ export class SetupWizard {
22
+ readonly #io: SetupIo;
23
+ readonly #serviceInstaller?: ServiceInstaller;
24
+ readonly #platform: string;
25
+ #defaults: Record<string, unknown> = {};
26
+
27
+ constructor(io: SetupIo, opts?: {
28
+ serviceInstaller?: ServiceInstaller;
29
+ platform?: string;
30
+ }) {
31
+ this.#io = io;
32
+ this.#serviceInstaller = opts?.serviceInstaller;
33
+ this.#platform = opts?.platform ?? process.platform;
46
34
  }
47
- }
48
-
49
- export async function runSetupWizard(io: SetupIO, opts?: { defaults?: SetupDefaults; serviceInstaller?: ServiceInstaller; onSettingsReady?: (settings: Settings) => void; resolveClaude?: () => string; platform?: string }): Promise<Settings> {
50
- const { ask, write } = io;
51
- const prev = opts?.defaults ?? {};
52
35
 
53
- // Fail fast if claude CLI is not installed
54
- const resolve = opts?.resolveClaude ?? resolveClaudePath;
55
- resolve();
56
-
57
- write("\n=== Macroclaw Setup ===\n\n");
58
-
59
- // Bot token
60
- const defaultToken = prev.botToken || process.env.TELEGRAM_BOT_TOKEN || "";
61
- const tokenPrompt = defaultToken ? `Bot token [${maskValue("botToken", defaultToken)}]: ` : "Bot token: ";
62
- let botToken = await ask(tokenPrompt) || defaultToken;
63
-
64
- // Validate token by starting a temporary bot
65
- let setupBot: Bot | null = null;
66
- while (true) {
67
- if (!botToken) {
68
- botToken = await ask("Bot token (required): ");
69
- continue;
70
- }
36
+ #resolveClaudePath(): void {
71
37
  try {
72
- setupBot = await startSetupBot(botToken);
73
- write(`Bot @${setupBot.botInfo.username} connected. Send /chatid to the bot to get your chat ID.\n`);
74
- break;
38
+ execSync("which claude", { encoding: "utf-8" });
75
39
  } catch {
76
- write("Invalid bot token. Please try again.\n");
77
- botToken = await ask("Bot token: ");
40
+ throw new Error("Claude Code CLI not found. Install it first: https://docs.anthropic.com/en/docs/claude-code");
78
41
  }
79
42
  }
80
43
 
81
- // Chat ID
82
- const defaultChatId = prev.chatId || process.env.AUTHORIZED_CHAT_ID || "";
83
- const chatIdPrompt = defaultChatId ? `Chat ID [${defaultChatId}]: ` : "Chat ID: ";
84
- let chatId = await ask(chatIdPrompt) || defaultChatId;
85
- while (!chatId) {
86
- chatId = await ask("Chat ID (required): ");
44
+ #default(key: string, fallback?: string): string {
45
+ const envVar = SettingsManager.envMapping[key as keyof Settings];
46
+ return (this.#defaults[key] as string) || (envVar && process.env[envVar]) || fallback || "";
87
47
  }
88
48
 
89
- // Stop setup bot
90
- if (setupBot) {
91
- await setupBot.stop();
49
+ async collectSettings(defaults?: Record<string, unknown>): Promise<Settings> {
50
+ this.#defaults = defaults ?? {};
51
+ this.#io.open();
52
+ try {
53
+ this.#resolveClaudePath();
54
+
55
+ this.#io.write("\n=== Macroclaw ===\n\n");
56
+ this.#io.write("Personal AI assistant, powered by Claude Code, delivered through Telegram.\n\n");
57
+ this.#io.write("=== Setup ===\n\n");
58
+
59
+ // Bot token
60
+ this.#io.write("First, set up a Telegram bot:\n");
61
+ this.#io.write(" 1. Open Telegram and message @BotFather\n");
62
+ this.#io.write(" 2. Send /newbot and follow the instructions\n");
63
+ this.#io.write(" 3. Copy the token it gives you (looks like 123456:ABC-DEF...)\n\n");
64
+ const { botToken, bot } = await this.#askBotToken();
65
+
66
+ // Chat ID
67
+ this.#io.write("Next, we need a chat ID. Macroclaw only accepts messages from a single\n");
68
+ this.#io.write("authorized chat — send /chatid to the bot in Telegram to get yours.\n\n");
69
+ const defaultChatId = this.#default("chatId");
70
+ const chatIdPrompt = defaultChatId ? `Chat ID [${defaultChatId}]: ` : "Chat ID: ";
71
+ const chatId = await this.#askValidated("chatId", chatIdPrompt, defaultChatId);
72
+
73
+ // Stop setup bot after chat ID is collected
74
+ await bot.stop();
75
+
76
+ // Model
77
+ this.#io.write("\nThe default Claude model for conversations (haiku, sonnet, opus).\n\n");
78
+ const defaultModel = this.#default("model", "sonnet");
79
+ const model = await this.#askValidated("model", `Model [${defaultModel}]: `, defaultModel);
80
+
81
+ // Workspace
82
+ this.#io.write("\nThe workspace directory where Claude Code runs — instructions, skills,\n");
83
+ this.#io.write("memory, and cron definitions all live here.\n\n");
84
+ const defaultWorkspace = this.#default("workspace", "~/.macroclaw-workspace");
85
+ const workspace = await this.#askValidated("workspace", `Workspace [${defaultWorkspace}]: `, defaultWorkspace);
86
+
87
+ // OpenAI API key
88
+ this.#io.write("\nMacroclaw uses OpenAI's Whisper API to transcribe voice messages.\n");
89
+ this.#io.write("Without this key, voice messages will be ignored.\n\n");
90
+ const defaultOpenai = this.#default("openaiApiKey");
91
+ const openaiPrompt = defaultOpenai ? `OpenAI API key [${maskValue("openaiApiKey", defaultOpenai)}] (optional): ` : "OpenAI API key (optional): ";
92
+ const openaiApiKey = await this.#askValidated("openaiApiKey", openaiPrompt, defaultOpenai) || undefined;
93
+
94
+ // Log level (non interactive)
95
+ const logLevel = this.#default("logLevel");
96
+
97
+ const settings: Settings = settingsSchema.parse({
98
+ botToken,
99
+ chatId,
100
+ model,
101
+ workspace,
102
+ openaiApiKey,
103
+ ...(logLevel && { logLevel }),
104
+ });
105
+
106
+ this.#io.write("\nSetup complete!\n\n");
107
+ return settings;
108
+ } finally {
109
+ this.#io.close();
110
+ }
92
111
  }
93
112
 
94
- // Model
95
- const defaultModel = prev.model || process.env.MODEL || "sonnet";
96
- const model = await ask(`Model [${defaultModel}]: `) || defaultModel;
97
-
98
- // Workspace
99
- const defaultWorkspace = prev.workspace || process.env.WORKSPACE || "~/.macroclaw-workspace";
100
- const workspace = await ask(`Workspace [${defaultWorkspace}]: `) || defaultWorkspace;
101
-
102
- // OpenAI API key
103
- const defaultOpenai = prev.openaiApiKey || process.env.OPENAI_API_KEY || "";
104
- const openaiPrompt = defaultOpenai ? `OpenAI API key [${maskValue("openaiApiKey", defaultOpenai)}] (optional): ` : "OpenAI API key (optional): ";
105
- const openaiApiKey = await ask(openaiPrompt) || defaultOpenai || undefined;
106
-
107
- const settings: Settings = settingsSchema.parse({
108
- botToken,
109
- chatId,
110
- model,
111
- workspace,
112
- openaiApiKey,
113
- logLevel: "debug",
114
- });
115
-
116
- // Persist settings before service install prompt so ServiceManager can find them
117
- opts?.onSettingsReady?.(settings);
118
-
119
- write("\nSetup complete!\n\n");
120
-
121
- // Optional service installation
122
- const installAnswer = await ask("Install as a system service? [Y/n]: ");
123
- let oauthToken: string | undefined;
124
- if (installAnswer.toLowerCase() !== "n" && installAnswer.toLowerCase() !== "no") {
125
- if ((opts?.platform ?? process.platform) === "darwin") {
126
- write("\nmacOS requires a long-lived OAuth token for the service.\n");
127
- write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
128
- oauthToken = await ask("OAuth token: ");
129
- if (!oauthToken) {
130
- write("No token provided. Skipping service installation.\n");
131
- io.close?.();
132
- return settings;
113
+ async installService(): Promise<void> {
114
+ this.#io.open();
115
+ try {
116
+ const installAnswer = await this.#io.ask("Install as a system service? [Y/n]: ");
117
+ if (installAnswer.toLowerCase() === "n" || installAnswer.toLowerCase() === "no") return;
118
+
119
+ let oauthToken: string | undefined;
120
+ if (this.#platform === "darwin") {
121
+ this.#io.write("\nmacOS requires a long-lived OAuth token for the service.\n");
122
+ this.#io.write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
123
+ oauthToken = await this.#io.ask("OAuth token: ");
124
+ if (!oauthToken) {
125
+ this.#io.write("No token provided. Skipping service installation.\n");
126
+ return;
127
+ }
128
+ }
129
+
130
+ try {
131
+ const svc = this.#serviceInstaller ?? new (await import("./service")).ServiceManager();
132
+ const logCmd = svc.install(oauthToken);
133
+ this.#io.write(`Service installed and started. Check logs:\n ${logCmd}\n`);
134
+ } catch (err) {
135
+ this.#io.write(`Service installation failed: ${(err as Error).message}\n`);
133
136
  }
137
+ } finally {
138
+ this.#io.close();
134
139
  }
135
140
  }
136
- // Release terminal control before sudo may prompt for a password
137
- io.close?.();
138
- if (installAnswer.toLowerCase() !== "n" && installAnswer.toLowerCase() !== "no") {
139
- try {
140
- const svc = opts?.serviceInstaller ?? new (await import("./service")).ServiceManager();
141
- const logCmd = svc.install(oauthToken);
142
- write(`Service installed and started. Check logs:\n ${logCmd}\n`);
143
- } catch (err) {
144
- write(`Service installation failed: ${(err as Error).message}\n`);
141
+
142
+ async #askValidated(field: SetupField, prompt: string, fallback: string): Promise<string> {
143
+ const schema = settingsSchema.shape[field];
144
+ let value = await this.#io.ask(prompt) || fallback;
145
+ while (true) {
146
+ const result = schema.safeParse(value);
147
+ if (result.success) return value;
148
+ const issue = result.error?.issues?.[0];
149
+ this.#io.write(`Invalid value: ${issue?.message ?? "validation failed"}. Please try again.\n`);
150
+ value = await this.#io.ask(prompt) || fallback;
145
151
  }
146
152
  }
147
153
 
148
- return settings;
154
+ async #startBot(token: string): Promise<Bot> {
155
+ const bot = new Bot(token);
156
+ bot.command("chatid", (ctx) => {
157
+ ctx.reply(ctx.chat.id.toString());
158
+ });
159
+ bot.catch((err) => {
160
+ log.debug({ err }, "Setup bot error");
161
+ });
162
+
163
+ await bot.init();
164
+ await bot.api.setMyCommands([{ command: "chatid", description: "Get your chat ID" }]);
165
+ // Fire-and-forget: long-polling loop, stop() aborts with "Aborted delay"
166
+ void bot.start().catch(() => {});
167
+ return bot;
168
+ }
169
+
170
+ async #askBotToken(): Promise<{ botToken: string; bot: Bot }> {
171
+ const defaultToken = this.#default("botToken");
172
+ const tokenPrompt = defaultToken ? `Bot token [${maskValue("botToken", defaultToken)}]: ` : "Bot token: ";
173
+ let botToken = await this.#askValidated("botToken", tokenPrompt, defaultToken);
174
+
175
+ // Validate token by starting a temporary bot
176
+ while (true) {
177
+ if (!botToken) {
178
+ botToken = await this.#askValidated("botToken", "Bot token (required): ", "");
179
+ continue;
180
+ }
181
+ try {
182
+ const bot = await this.#startBot(botToken);
183
+ this.#io.write(`\nBot @${bot.botInfo.username} connected.\n\n`);
184
+ return { botToken, bot };
185
+ } catch {
186
+ this.#io.write("Invalid bot token. Please try again.\n");
187
+ botToken = await this.#io.ask("Bot token: ");
188
+ }
189
+ }
190
+ }
149
191
  }