macroclaw 0.11.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/app.test.ts +14 -22
- package/src/app.ts +5 -4
- package/src/cli.test.ts +88 -128
- package/src/cli.ts +37 -76
- package/src/index.ts +9 -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/speech-to-text.ts +28 -0
- package/src/stt.ts +0 -31
package/README.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
# Macroclaw
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Personal AI assistant, powered by Claude Code, delivered through Telegram.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
ToS issues with using a Claude subscription programmatically.
|
|
5
|
+
A lightweight bridge that turns a Telegram chat into a personal AI assistant — one that remembers context across conversations, runs background tasks, handles files and voice messages, and can also write and debug code. Built on the Claude Code CLI (`claude -p`) to stay **compliant with Anthropic's ToS**. The platform handles Telegram I/O, process orchestration, and scheduling; everything else — personality, skills, memory, behavior — lives in a customizable workspace.
|
|
7
6
|
|
|
8
7
|
## Security Model
|
|
9
8
|
|
|
@@ -48,6 +47,8 @@ Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
|
|
|
48
47
|
| `logLevel` | `LOG_LEVEL` | `debug` | No |
|
|
49
48
|
| `pinoramaUrl` | `PINORAMA_URL` | — | No |
|
|
50
49
|
|
|
50
|
+
**`openaiApiKey`** is used for voice message transcription via [OpenAI Whisper](https://platform.openai.com/docs/guides/speech-to-text). Without it, voice messages are ignored.
|
|
51
|
+
|
|
51
52
|
Env vars take precedence over settings file values. On startup, a masked settings summary is printed showing which values were overridden by env vars.
|
|
52
53
|
|
|
53
54
|
Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
|
package/package.json
CHANGED
package/src/app.test.ts
CHANGED
|
@@ -3,18 +3,13 @@ import { existsSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { App, type AppConfig } from "./app";
|
|
4
4
|
import { type Claude, QueryProcessError, type QueryResult, type RunningQuery } from "./claude";
|
|
5
5
|
import { saveSessions } from "./sessions";
|
|
6
|
+
import type { SpeechToText } from "./speech-to-text";
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const mockTranscribe = mock(async (_filePath: string) => "transcribed text");
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
transcriptions: {
|
|
13
|
-
create: mockOpenAICreate,
|
|
14
|
-
},
|
|
15
|
-
};
|
|
16
|
-
},
|
|
17
|
-
}));
|
|
10
|
+
function mockStt(): SpeechToText {
|
|
11
|
+
return { transcribe: mockTranscribe } as unknown as SpeechToText;
|
|
12
|
+
}
|
|
18
13
|
|
|
19
14
|
// Mock Grammy Bot
|
|
20
15
|
mock.module("grammy", () => ({
|
|
@@ -59,17 +54,14 @@ mock.module("grammy", () => ({
|
|
|
59
54
|
|
|
60
55
|
const tmpSettingsDir = "/tmp/macroclaw-test-settings";
|
|
61
56
|
|
|
62
|
-
const savedOpenAIKey = process.env.OPENAI_API_KEY;
|
|
63
|
-
|
|
64
57
|
beforeEach(() => {
|
|
65
|
-
|
|
58
|
+
mockTranscribe.mockReset();
|
|
59
|
+
mockTranscribe.mockImplementation(async () => "transcribed text");
|
|
66
60
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
67
61
|
saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
|
|
68
62
|
});
|
|
69
63
|
|
|
70
64
|
afterEach(() => {
|
|
71
|
-
if (savedOpenAIKey) process.env.OPENAI_API_KEY = savedOpenAIKey;
|
|
72
|
-
else delete process.env.OPENAI_API_KEY;
|
|
73
65
|
if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
|
|
74
66
|
});
|
|
75
67
|
|
|
@@ -128,6 +120,7 @@ function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
|
128
120
|
workspace: "/tmp/macroclaw-test-workspace",
|
|
129
121
|
settingsDir: tmpSettingsDir,
|
|
130
122
|
claude: defaultMockClaude(),
|
|
123
|
+
stt: mockStt(),
|
|
131
124
|
...overrides,
|
|
132
125
|
};
|
|
133
126
|
}
|
|
@@ -350,7 +343,7 @@ describe("App", () => {
|
|
|
350
343
|
globalThis.fetch = mock(() =>
|
|
351
344
|
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
352
345
|
) as any;
|
|
353
|
-
|
|
346
|
+
mockTranscribe.mockImplementationOnce(async () => "hello from voice");
|
|
354
347
|
|
|
355
348
|
const config = makeConfig();
|
|
356
349
|
const app = new App(config);
|
|
@@ -380,7 +373,7 @@ describe("App", () => {
|
|
|
380
373
|
globalThis.fetch = mock(() =>
|
|
381
374
|
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
382
375
|
) as any;
|
|
383
|
-
|
|
376
|
+
mockTranscribe.mockImplementationOnce(async () => { throw new Error("API error"); });
|
|
384
377
|
|
|
385
378
|
const config = makeConfig();
|
|
386
379
|
const app = new App(config);
|
|
@@ -406,7 +399,7 @@ describe("App", () => {
|
|
|
406
399
|
globalThis.fetch = mock(() =>
|
|
407
400
|
Promise.resolve(new Response("fake-audio", { status: 200 })),
|
|
408
401
|
) as any;
|
|
409
|
-
|
|
402
|
+
mockTranscribe.mockImplementationOnce(async () => " ");
|
|
410
403
|
|
|
411
404
|
const config = makeConfig();
|
|
412
405
|
const app = new App(config);
|
|
@@ -442,9 +435,8 @@ describe("App", () => {
|
|
|
442
435
|
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
443
436
|
});
|
|
444
437
|
|
|
445
|
-
it("responds with unavailable message when
|
|
446
|
-
|
|
447
|
-
const config = makeConfig();
|
|
438
|
+
it("responds with unavailable message when stt is not configured", async () => {
|
|
439
|
+
const config = makeConfig({ stt: undefined });
|
|
448
440
|
const app = new App(config);
|
|
449
441
|
const bot = app.bot as any;
|
|
450
442
|
const handler = bot.filterHandlers.get("message:voice")![0];
|
|
@@ -455,7 +447,7 @@ describe("App", () => {
|
|
|
455
447
|
});
|
|
456
448
|
|
|
457
449
|
const sendCalls = (bot.api.sendMessage as any).mock.calls;
|
|
458
|
-
const call = sendCalls.find((c: any) => c[1].includes("
|
|
450
|
+
const call = sendCalls.find((c: any) => c[1].includes("openaiApiKey"));
|
|
459
451
|
expect(call).toBeDefined();
|
|
460
452
|
expect((config.claude as Claude & { calls: CallInfo[] }).calls).toHaveLength(0);
|
|
461
453
|
});
|
package/src/app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Bot } from "grammy";
|
|
|
2
2
|
import { CronScheduler } from "./cron";
|
|
3
3
|
import { createLogger } from "./logger";
|
|
4
4
|
import { type Claude, Orchestrator, type OrchestratorResponse } from "./orchestrator";
|
|
5
|
-
import {
|
|
5
|
+
import type { SpeechToText } from "./speech-to-text";
|
|
6
6
|
import { createBot, downloadFile, sendFile, sendResponse } from "./telegram";
|
|
7
7
|
|
|
8
8
|
const log = createLogger("app");
|
|
@@ -14,6 +14,7 @@ export interface AppConfig {
|
|
|
14
14
|
model?: string;
|
|
15
15
|
settingsDir?: string;
|
|
16
16
|
claude?: Claude;
|
|
17
|
+
stt?: SpeechToText;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export class App {
|
|
@@ -118,13 +119,13 @@ export class App {
|
|
|
118
119
|
|
|
119
120
|
this.#bot.on("message:voice", async (ctx) => {
|
|
120
121
|
if (ctx.chat.id.toString() !== this.#config.authorizedChatId) return;
|
|
121
|
-
if (!
|
|
122
|
-
await sendResponse(this.#bot, this.#config.authorizedChatId, "[Voice messages not available — set
|
|
122
|
+
if (!this.#config.stt) {
|
|
123
|
+
await sendResponse(this.#bot, this.#config.authorizedChatId, "[Voice messages not available — set openaiApiKey in settings to enable]");
|
|
123
124
|
return;
|
|
124
125
|
}
|
|
125
126
|
try {
|
|
126
127
|
const path = await downloadFile(this.#bot, ctx.message.voice.file_id, this.#config.botToken, "voice.ogg");
|
|
127
|
-
const text = await transcribe(path);
|
|
128
|
+
const text = await this.#config.stt.transcribe(path);
|
|
128
129
|
if (!text.trim()) {
|
|
129
130
|
await sendResponse(this.#bot, this.#config.authorizedChatId, "[Could not understand audio]");
|
|
130
131
|
return;
|
package/src/cli.test.ts
CHANGED
|
@@ -1,34 +1,39 @@
|
|
|
1
1
|
import { describe, expect, it, mock } from "bun:test";
|
|
2
2
|
import { runCommand } from "citty";
|
|
3
|
-
import { Cli,
|
|
3
|
+
import { Cli, createReadlineIo, handleError } from "./cli";
|
|
4
4
|
import type { SystemService } from "./service";
|
|
5
|
+
import { SettingsManager } from "./settings";
|
|
6
|
+
import type { SetupWizard } from "./setup";
|
|
5
7
|
|
|
6
8
|
// Only mock ./index — safe since no other test imports it
|
|
7
9
|
const mockStart = mock(async () => {});
|
|
8
10
|
mock.module("./index", () => ({ start: mockStart }));
|
|
9
11
|
|
|
12
|
+
// Mock child_process so cli.claude() doesn't spawn real processes
|
|
13
|
+
const mockExecSync = mock((_cmd: string, _opts?: object) => "");
|
|
14
|
+
mock.module("node:child_process", () => ({
|
|
15
|
+
execSync: mockExecSync,
|
|
16
|
+
execFileSync: () => "",
|
|
17
|
+
}));
|
|
18
|
+
|
|
10
19
|
const { main } = await import("./cli");
|
|
11
20
|
|
|
12
|
-
function
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
},
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
resolveDir: () => "/home/test/.macroclaw",
|
|
29
|
-
};
|
|
30
|
-
result.saveSettings = (settings: unknown, dir: string) => { result.saved = { settings, dir }; };
|
|
31
|
-
return result;
|
|
21
|
+
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void> }) {
|
|
22
|
+
return {
|
|
23
|
+
collectSettings: overrides?.collectSettings ?? mock(async () => ({ botToken: "tok", chatId: "123" })),
|
|
24
|
+
installService: overrides?.installService ?? mock(async () => {}),
|
|
25
|
+
} as unknown as SetupWizard;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMockSettings(overrides?: Partial<SettingsManager>) {
|
|
29
|
+
return {
|
|
30
|
+
load: mock(() => ({ botToken: "tok", chatId: "123", model: "sonnet", workspace: "/tmp" })),
|
|
31
|
+
loadRaw: mock(() => null),
|
|
32
|
+
save: mock(() => {}),
|
|
33
|
+
applyEnvOverrides: mock((s: unknown) => ({ settings: s, overrides: new Set() })),
|
|
34
|
+
print: mock(() => {}),
|
|
35
|
+
...overrides,
|
|
36
|
+
} as unknown as SettingsManager;
|
|
32
37
|
}
|
|
33
38
|
|
|
34
39
|
describe("CLI routing", () => {
|
|
@@ -62,49 +67,45 @@ describe("CLI routing", () => {
|
|
|
62
67
|
|
|
63
68
|
describe("Cli.setup", () => {
|
|
64
69
|
it("runs wizard and saves settings", async () => {
|
|
65
|
-
const
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
expect(deps.saved).toEqual({
|
|
69
|
-
settings: { botToken: "tok", chatId: "123" },
|
|
70
|
-
dir: "/home/test/.macroclaw",
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("calls initLogger", async () => {
|
|
75
|
-
const deps = createMockSetupDeps();
|
|
76
|
-
const mockInit = mock(async () => {});
|
|
77
|
-
deps.initLogger = mockInit;
|
|
78
|
-
const cli = new Cli(deps);
|
|
70
|
+
const wizard = createMockWizard();
|
|
71
|
+
const settings = createMockSettings();
|
|
72
|
+
const cli = new Cli({ wizard, settings });
|
|
79
73
|
await cli.setup();
|
|
80
|
-
expect(
|
|
74
|
+
expect((settings.save as ReturnType<typeof mock>)).toHaveBeenCalledWith({ botToken: "tok", chatId: "123" });
|
|
81
75
|
});
|
|
82
76
|
|
|
83
77
|
it("passes existing settings as defaults to wizard", async () => {
|
|
84
78
|
const existing = { botToken: "old-tok", chatId: "999" };
|
|
85
79
|
let receivedDefaults: unknown = null;
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return { botToken: "tok", chatId: "123" };
|
|
92
|
-
};
|
|
93
|
-
const cli = new Cli(deps);
|
|
80
|
+
const wizard = createMockWizard({
|
|
81
|
+
collectSettings: async (defaults) => { receivedDefaults = defaults; return { botToken: "tok", chatId: "123" }; },
|
|
82
|
+
});
|
|
83
|
+
const settings = createMockSettings({ loadRaw: () => existing } as unknown as Partial<SettingsManager>);
|
|
84
|
+
const cli = new Cli({ wizard, settings });
|
|
94
85
|
await cli.setup();
|
|
95
86
|
expect(receivedDefaults).toEqual(existing);
|
|
96
87
|
});
|
|
97
88
|
|
|
98
|
-
it("
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
deps.createReadlineInterface = () => ({
|
|
102
|
-
question: (_q: string, cb: (a: string) => void) => cb("answer"),
|
|
103
|
-
close: mockClose,
|
|
104
|
-
});
|
|
105
|
-
const cli = new Cli(deps);
|
|
89
|
+
it("wizard manages io lifecycle internally", async () => {
|
|
90
|
+
const wizard = createMockWizard();
|
|
91
|
+
const cli = new Cli({ wizard, settings: createMockSettings() });
|
|
106
92
|
await cli.setup();
|
|
107
|
-
expect(
|
|
93
|
+
expect((wizard.collectSettings as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
94
|
+
expect((wizard.installService as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("creates default wizard when none provided", () => {
|
|
98
|
+
const cli = new Cli({ settings: createMockSettings() });
|
|
99
|
+
expect(cli).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("createReadlineIo creates functional io", async () => {
|
|
103
|
+
const io = createReadlineIo();
|
|
104
|
+
io.open();
|
|
105
|
+
const answer = io.ask("test? ");
|
|
106
|
+
process.stdin.push("hello\n");
|
|
107
|
+
expect(await answer).toBe("hello");
|
|
108
|
+
io.close();
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
|
|
@@ -124,67 +125,67 @@ function mockService(overrides?: Partial<SystemService>): SystemService {
|
|
|
124
125
|
describe("Cli.service", () => {
|
|
125
126
|
it("runs install action", () => {
|
|
126
127
|
const install = mock(() => "tail -f /logs");
|
|
127
|
-
const cli = new Cli(
|
|
128
|
+
const cli = new Cli({ systemService: mockService({ install }) });
|
|
128
129
|
cli.service("install");
|
|
129
130
|
expect(install).toHaveBeenCalled();
|
|
130
131
|
});
|
|
131
132
|
|
|
132
133
|
it("runs uninstall action", () => {
|
|
133
134
|
const uninstall = mock(() => {});
|
|
134
|
-
const cli = new Cli(
|
|
135
|
+
const cli = new Cli({ systemService: mockService({ uninstall }) });
|
|
135
136
|
cli.service("uninstall");
|
|
136
137
|
expect(uninstall).toHaveBeenCalled();
|
|
137
138
|
});
|
|
138
139
|
|
|
139
140
|
it("runs start action", () => {
|
|
140
141
|
const start = mock(() => "tail -f /logs");
|
|
141
|
-
const cli = new Cli(
|
|
142
|
+
const cli = new Cli({ systemService: mockService({ start }) });
|
|
142
143
|
cli.service("start");
|
|
143
144
|
expect(start).toHaveBeenCalled();
|
|
144
145
|
});
|
|
145
146
|
|
|
146
147
|
it("runs stop action", () => {
|
|
147
148
|
const stop = mock(() => {});
|
|
148
|
-
const cli = new Cli(
|
|
149
|
+
const cli = new Cli({ systemService: mockService({ stop }) });
|
|
149
150
|
cli.service("stop");
|
|
150
151
|
expect(stop).toHaveBeenCalled();
|
|
151
152
|
});
|
|
152
153
|
|
|
153
154
|
it("runs update action", () => {
|
|
154
155
|
const update = mock(() => "tail -f /logs");
|
|
155
|
-
const cli = new Cli(
|
|
156
|
+
const cli = new Cli({ systemService: mockService({ update }) });
|
|
156
157
|
cli.service("update");
|
|
157
158
|
expect(update).toHaveBeenCalled();
|
|
158
159
|
});
|
|
159
160
|
|
|
160
161
|
it("runs status action", () => {
|
|
161
162
|
const status = mock(() => ({ installed: true, running: true, platform: "systemd" as const, pid: 42, uptime: "Thu 2026-03-12 10:00:00 UTC" }));
|
|
162
|
-
const cli = new Cli(
|
|
163
|
+
const cli = new Cli({ systemService: mockService({ status }) });
|
|
163
164
|
cli.service("status");
|
|
164
165
|
expect(status).toHaveBeenCalled();
|
|
165
166
|
});
|
|
166
167
|
|
|
167
168
|
it("runs logs action", () => {
|
|
168
169
|
const logs = mock(() => "journalctl -u macroclaw -n 50 --no-pager");
|
|
169
|
-
const cli = new Cli(
|
|
170
|
+
const cli = new Cli({ systemService: mockService({ logs }) });
|
|
170
171
|
cli.service("logs");
|
|
171
172
|
expect(logs).toHaveBeenCalledWith(undefined);
|
|
172
173
|
});
|
|
173
174
|
|
|
174
175
|
it("passes follow flag to logs action", () => {
|
|
175
176
|
const logs = mock(() => "journalctl -u macroclaw -f");
|
|
176
|
-
const cli = new Cli(
|
|
177
|
+
const cli = new Cli({ systemService: mockService({ logs }) });
|
|
177
178
|
cli.service("logs", undefined, true);
|
|
178
179
|
expect(logs).toHaveBeenCalledWith(true);
|
|
179
180
|
});
|
|
180
181
|
|
|
181
182
|
it("throws for unknown action", () => {
|
|
182
|
-
const cli = new Cli(
|
|
183
|
+
const cli = new Cli({ systemService: mockService() });
|
|
183
184
|
expect(() => cli.service("bogus")).toThrow("Unknown service action: bogus");
|
|
184
185
|
});
|
|
185
186
|
|
|
186
187
|
it("throws service errors", () => {
|
|
187
|
-
const cli = new Cli(
|
|
188
|
+
const cli = new Cli({ systemService: mockService({ install: () => { throw new Error("Settings not found."); } }) });
|
|
188
189
|
expect(() => cli.service("install")).toThrow("Settings not found.");
|
|
189
190
|
});
|
|
190
191
|
});
|
|
@@ -197,21 +198,18 @@ describe("Cli.claude", () => {
|
|
|
197
198
|
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123", model: "opus", workspace: "/tmp" }));
|
|
198
199
|
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({ mainSessionId: "sess-123" }));
|
|
199
200
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const deps = createMockSetupDeps();
|
|
205
|
-
deps.resolveDir = () => dir;
|
|
206
|
-
const cli = new Cli(deps);
|
|
207
|
-
cli.claude(exec);
|
|
201
|
+
mockExecSync.mockClear();
|
|
202
|
+
const cli = new Cli({ settings: new SettingsManager(dir) });
|
|
203
|
+
cli.claude();
|
|
208
204
|
|
|
209
205
|
fs.rmSync(dir, { recursive: true });
|
|
210
206
|
|
|
211
|
-
expect(
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
207
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
208
|
+
"claude --resume sess-123 --model opus",
|
|
209
|
+
expect.objectContaining({ cwd: "/tmp", stdio: "inherit" }),
|
|
210
|
+
);
|
|
211
|
+
const opts = mockExecSync.mock.calls[0][1] as { env: Record<string, string> };
|
|
212
|
+
expect(opts.env.CLAUDECODE).toBe("");
|
|
215
213
|
});
|
|
216
214
|
|
|
217
215
|
it("omits --resume when no session exists", async () => {
|
|
@@ -221,64 +219,26 @@ describe("Cli.claude", () => {
|
|
|
221
219
|
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123", model: "sonnet", workspace: "/tmp" }));
|
|
222
220
|
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({}));
|
|
223
221
|
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
const deps = createMockSetupDeps();
|
|
228
|
-
deps.resolveDir = () => dir;
|
|
229
|
-
const cli = new Cli(deps);
|
|
230
|
-
cli.claude(exec);
|
|
222
|
+
mockExecSync.mockClear();
|
|
223
|
+
const cli = new Cli({ settings: new SettingsManager(dir) });
|
|
224
|
+
cli.claude();
|
|
231
225
|
|
|
232
226
|
fs.rmSync(dir, { recursive: true });
|
|
233
227
|
|
|
234
|
-
expect(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const deps = createMockSetupDeps();
|
|
239
|
-
deps.resolveDir = () => "/nonexistent/path";
|
|
240
|
-
const cli = new Cli(deps);
|
|
241
|
-
expect(() => cli.claude(mock())).toThrow("Settings not found");
|
|
242
|
-
});
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
describe("loadRawSettings", () => {
|
|
246
|
-
it("returns null when file does not exist", () => {
|
|
247
|
-
expect(loadRawSettings("/nonexistent/path")).toBeNull();
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it("reads and parses valid settings file", async () => {
|
|
251
|
-
const dir = await import("node:fs").then(fs => {
|
|
252
|
-
const d = `/tmp/macroclaw-test-${Date.now()}`;
|
|
253
|
-
fs.mkdirSync(d, { recursive: true });
|
|
254
|
-
fs.writeFileSync(`${d}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123" }));
|
|
255
|
-
return d;
|
|
256
|
-
});
|
|
257
|
-
const result = loadRawSettings(dir);
|
|
258
|
-
expect(result).toEqual({ botToken: "tok", chatId: "123" });
|
|
259
|
-
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
it("returns null for invalid JSON", async () => {
|
|
263
|
-
const dir = await import("node:fs").then(fs => {
|
|
264
|
-
const d = `/tmp/macroclaw-test-${Date.now()}`;
|
|
265
|
-
fs.mkdirSync(d, { recursive: true });
|
|
266
|
-
fs.writeFileSync(`${d}/settings.json`, "not json");
|
|
267
|
-
return d;
|
|
268
|
-
});
|
|
269
|
-
expect(loadRawSettings(dir)).toBeNull();
|
|
270
|
-
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
228
|
+
expect(mockExecSync).toHaveBeenCalledWith(
|
|
229
|
+
"claude --model sonnet",
|
|
230
|
+
expect.objectContaining({ cwd: "/tmp", stdio: "inherit" }),
|
|
231
|
+
);
|
|
271
232
|
});
|
|
272
233
|
|
|
273
|
-
it("
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
expect(
|
|
281
|
-
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
234
|
+
it("exits when settings are missing", () => {
|
|
235
|
+
const mockExit = mock((_code?: number) => { throw new Error("exit"); });
|
|
236
|
+
const origExit = process.exit;
|
|
237
|
+
process.exit = mockExit as typeof process.exit;
|
|
238
|
+
const cli = new Cli({ settings: new SettingsManager("/nonexistent/path") });
|
|
239
|
+
expect(() => cli.claude()).toThrow("exit");
|
|
240
|
+
process.exit = origExit;
|
|
241
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
282
242
|
});
|
|
283
243
|
});
|
|
284
244
|
|
package/src/cli.ts
CHANGED
|
@@ -1,115 +1,66 @@
|
|
|
1
1
|
import {execSync} from "node:child_process";
|
|
2
|
-
import {existsSync, readFileSync} from "node:fs";
|
|
3
|
-
import {join, resolve} from "node:path";
|
|
4
2
|
import {createInterface} from "node:readline";
|
|
5
3
|
import {defineCommand} from "citty";
|
|
6
|
-
import pkg from "../package.json" with {
|
|
7
|
-
import {initLogger} from "./logger";
|
|
4
|
+
import pkg from "../package.json" with {type: "json"};
|
|
8
5
|
import {ServiceManager, type SystemService} from "./service";
|
|
9
6
|
import {loadSessions} from "./sessions";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
export interface SetupDeps {
|
|
14
|
-
initLogger: () => Promise<void>;
|
|
15
|
-
saveSettings: (settings: unknown, dir: string) => void;
|
|
16
|
-
loadRawSettings: (dir: string) => Record<string, unknown> | null;
|
|
17
|
-
runSetupWizard: (io: { ask: (q: string) => Promise<string>; write: (m: string) => void; close?: () => void }, opts?: { defaults?: Record<string, unknown>; onSettingsReady?: (settings: unknown) => void }) => Promise<unknown>;
|
|
18
|
-
createReadlineInterface: () => { question: (q: string, cb: (a: string) => void) => void; close: () => void };
|
|
19
|
-
resolveDir: () => string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function loadRawSettings(dir: string): Record<string, unknown> | null {
|
|
23
|
-
const path = join(dir, "settings.json");
|
|
24
|
-
if (!existsSync(path)) return null;
|
|
25
|
-
try {
|
|
26
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
27
|
-
return typeof raw === "object" && raw !== null ? raw : null;
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function defaultSetupDeps(): SetupDeps {
|
|
34
|
-
return {
|
|
35
|
-
initLogger,
|
|
36
|
-
saveSettings: saveSettings as (settings: unknown, dir: string) => void,
|
|
37
|
-
loadRawSettings,
|
|
38
|
-
runSetupWizard: runSetupWizard as SetupDeps["runSetupWizard"],
|
|
39
|
-
createReadlineInterface: () => createInterface({ input: process.stdin, output: process.stdout }),
|
|
40
|
-
resolveDir: () => resolve(process.env.HOME || "~", ".macroclaw"),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function defaultSystemService(): SystemService {
|
|
45
|
-
return new ServiceManager();
|
|
46
|
-
}
|
|
7
|
+
import {SettingsManager} from "./settings";
|
|
8
|
+
import {type SetupIo, SetupWizard} from "./setup";
|
|
47
9
|
|
|
48
10
|
export class Cli {
|
|
49
|
-
readonly #
|
|
50
|
-
readonly #
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
this.#
|
|
11
|
+
readonly #settingsManager: SettingsManager;
|
|
12
|
+
readonly #setupWizard: SetupWizard;
|
|
13
|
+
readonly #serviceManager: SystemService;
|
|
14
|
+
|
|
15
|
+
constructor(opts?: { wizard?: SetupWizard; settings?: SettingsManager; systemService?: SystemService }) {
|
|
16
|
+
this.#settingsManager = opts?.settings ?? new SettingsManager();
|
|
17
|
+
this.#setupWizard = opts?.wizard ?? new SetupWizard(createReadlineIo());
|
|
18
|
+
this.#serviceManager = opts?.systemService ?? new ServiceManager();
|
|
55
19
|
}
|
|
56
20
|
|
|
57
21
|
async setup(): Promise<void> {
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
new Promise((res) => rl.question(question, (answer: string) => res(answer.trim()))),
|
|
63
|
-
write: (msg: string) => process.stdout.write(msg),
|
|
64
|
-
close: () => rl.close(),
|
|
65
|
-
};
|
|
66
|
-
const dir = this.#setupDeps.resolveDir();
|
|
67
|
-
const defaults = this.#setupDeps.loadRawSettings(dir) ?? undefined;
|
|
68
|
-
const settings = await this.#setupDeps.runSetupWizard(io, {
|
|
69
|
-
defaults,
|
|
70
|
-
onSettingsReady: (s) => this.#setupDeps.saveSettings(s, dir),
|
|
71
|
-
});
|
|
72
|
-
this.#setupDeps.saveSettings(settings, dir);
|
|
22
|
+
const defaults = this.#settingsManager.loadRaw() ?? undefined;
|
|
23
|
+
const settings = await this.#setupWizard.collectSettings(defaults);
|
|
24
|
+
this.#settingsManager.save(settings);
|
|
25
|
+
await this.#setupWizard.installService();
|
|
73
26
|
}
|
|
74
27
|
|
|
75
|
-
claude(
|
|
76
|
-
const
|
|
77
|
-
const
|
|
78
|
-
if (!settings) throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
79
|
-
const sessions = loadSessions(dir);
|
|
28
|
+
claude(): void {
|
|
29
|
+
const settings = this.#settingsManager.load();
|
|
30
|
+
const sessions = loadSessions(this.#settingsManager.dir);
|
|
80
31
|
const args = ["claude"];
|
|
81
32
|
if (sessions.mainSessionId) args.push("--resume", sessions.mainSessionId);
|
|
82
33
|
args.push("--model", settings.model);
|
|
83
|
-
|
|
34
|
+
execSync(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
|
|
84
35
|
}
|
|
85
36
|
|
|
86
37
|
service(action: string, token?: string, follow?: boolean): void {
|
|
87
38
|
switch (action) {
|
|
88
39
|
case "install": {
|
|
89
|
-
const logCmd = this.#
|
|
40
|
+
const logCmd = this.#serviceManager.install(token);
|
|
90
41
|
console.log(`Service installed and started. Check logs:\n ${logCmd}`);
|
|
91
42
|
break;
|
|
92
43
|
}
|
|
93
44
|
case "uninstall":
|
|
94
|
-
this.#
|
|
45
|
+
this.#serviceManager.uninstall();
|
|
95
46
|
console.log("Service uninstalled.");
|
|
96
47
|
break;
|
|
97
48
|
case "start": {
|
|
98
|
-
const logCmd = this.#
|
|
49
|
+
const logCmd = this.#serviceManager.start();
|
|
99
50
|
console.log(`Service started. Check logs:\n ${logCmd}`);
|
|
100
51
|
break;
|
|
101
52
|
}
|
|
102
53
|
case "stop":
|
|
103
|
-
this.#
|
|
54
|
+
this.#serviceManager.stop();
|
|
104
55
|
console.log("Service stopped.");
|
|
105
56
|
break;
|
|
106
57
|
case "update": {
|
|
107
|
-
const logCmd = this.#
|
|
58
|
+
const logCmd = this.#serviceManager.update();
|
|
108
59
|
console.log(`Service updated. Check logs:\n ${logCmd}`);
|
|
109
60
|
break;
|
|
110
61
|
}
|
|
111
62
|
case "status": {
|
|
112
|
-
const s = this.#
|
|
63
|
+
const s = this.#serviceManager.status();
|
|
113
64
|
const lines = [
|
|
114
65
|
`Platform: ${s.platform}`,
|
|
115
66
|
`Installed: ${s.installed ? "yes" : "no"}`,
|
|
@@ -121,7 +72,7 @@ export class Cli {
|
|
|
121
72
|
break;
|
|
122
73
|
}
|
|
123
74
|
case "logs": {
|
|
124
|
-
const cmd = this.#
|
|
75
|
+
const cmd = this.#serviceManager.logs(follow);
|
|
125
76
|
console.log(cmd);
|
|
126
77
|
break;
|
|
127
78
|
}
|
|
@@ -131,6 +82,17 @@ export class Cli {
|
|
|
131
82
|
}
|
|
132
83
|
}
|
|
133
84
|
|
|
85
|
+
export function createReadlineIo(): SetupIo {
|
|
86
|
+
let rl: ReturnType<typeof createInterface> | null = null;
|
|
87
|
+
return {
|
|
88
|
+
open: () => { rl = createInterface({ input: process.stdin, output: process.stdout }); },
|
|
89
|
+
close: () => { rl?.close(); rl = null; },
|
|
90
|
+
ask: (question: string): Promise<string> =>
|
|
91
|
+
new Promise((res) => rl?.question(question, (answer: string) => res(answer.trim()))),
|
|
92
|
+
write: (msg: string) => process.stdout.write(msg),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
134
96
|
export function handleError(err: unknown): never {
|
|
135
97
|
console.error(err instanceof Error ? err.message : String(err));
|
|
136
98
|
process.exit(1);
|
|
@@ -216,4 +178,3 @@ export const main = defineCommand({
|
|
|
216
178
|
meta: { name: pkg.name, description: pkg.description, version: pkg.version },
|
|
217
179
|
subCommands: { start: startCommand, setup: setupCommand, claude: claudeCommand, service: serviceCommand },
|
|
218
180
|
});
|
|
219
|
-
|