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/README.md +4 -3
- package/package.json +1 -1
- package/src/cli.test.ts +88 -128
- package/src/cli.ts +37 -76
- package/src/index.ts +7 -14
- package/src/logger.test.ts +7 -7
- package/src/logger.ts +1 -1
- package/src/settings.test.ts +65 -28
- package/src/settings.ts +81 -60
- package/src/setup.test.ts +63 -67
- package/src/setup.ts +159 -117
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 {
|
|
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:
|
|
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 {
|
|
59
|
+
const { SetupWizard } = await import("./setup");
|
|
59
60
|
|
|
60
|
-
function
|
|
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("
|
|
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
|
|
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("
|
|
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 = "
|
|
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
|
|
144
|
+
const settings = await runSetup(io);
|
|
136
145
|
|
|
137
146
|
expect(settings.botToken).toBe("env-token");
|
|
138
|
-
expect(settings.chatId).toBe("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
237
|
+
const settings = await runSetup(io);
|
|
229
238
|
|
|
230
239
|
expect(settings.chatId).toBe("456");
|
|
231
240
|
});
|
|
232
241
|
|
|
233
|
-
it("
|
|
242
|
+
it("re-prompts when chat ID is not numeric", async () => {
|
|
234
243
|
const io = createMockIO([
|
|
235
244
|
"tok",
|
|
236
|
-
"
|
|
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
|
|
253
|
+
const settings = await runSetup(io);
|
|
244
254
|
|
|
245
|
-
|
|
246
|
-
expect(
|
|
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("
|
|
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
|
|
270
|
+
const settings = await runSetup(io);
|
|
262
271
|
|
|
263
|
-
expect(
|
|
264
|
-
|
|
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("
|
|
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
|
-
"
|
|
280
|
-
"sk-test-token", // oauth token (macOS)
|
|
283
|
+
"", // no service install
|
|
281
284
|
]);
|
|
282
285
|
|
|
283
|
-
await
|
|
286
|
+
await runSetup(io);
|
|
284
287
|
|
|
285
|
-
|
|
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("
|
|
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
|
-
"
|
|
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
|
|
304
|
+
await runSetup(io);
|
|
302
305
|
|
|
303
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
write(`
|
|
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
|
-
|
|
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
|
}
|