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 CHANGED
@@ -1,9 +1,8 @@
1
1
  # Macroclaw
2
2
 
3
- Telegram-to-Claude-Code bridge. Bun + Grammy.
3
+ Personal AI assistant, powered by Claude Code, delivered through Telegram.
4
4
 
5
- Uses the Claude Code CLI (`claude -p`) rather than the Agent SDK to avoid any possible
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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 mockOpenAICreate = mock(async () => ({ text: "transcribed text" }));
8
+ const mockTranscribe = mock(async (_filePath: string) => "transcribed text");
8
9
 
9
- mock.module("openai", () => ({
10
- default: class MockOpenAI {
11
- audio = {
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
- process.env.OPENAI_API_KEY = "test-key";
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
- mockOpenAICreate.mockImplementationOnce(async () => ({ text: "hello from voice" }));
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
- mockOpenAICreate.mockImplementationOnce(async () => { throw new Error("API error"); });
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
- mockOpenAICreate.mockImplementationOnce(async () => ({ text: " " }));
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 OPENAI_API_KEY is not set", async () => {
446
- delete process.env.OPENAI_API_KEY;
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("OPENAI_API_KEY"));
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 { isAvailable as isSttAvailable, transcribe } from "./stt";
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 (!isSttAvailable()) {
122
- await sendResponse(this.#bot, this.#config.authorizedChatId, "[Voice messages not available — set OPENAI_API_KEY to enable]");
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, handleError, loadRawSettings, type SetupDeps } from "./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 createMockSetupDeps(): SetupDeps & { saved: { settings: unknown; dir: string } | null } {
13
- const result: SetupDeps & { saved: { settings: unknown; dir: string } | null } = {
14
- saved: null,
15
- initLogger: async () => {},
16
- saveSettings: () => {},
17
- loadRawSettings: () => null,
18
- runSetupWizard: async (io) => {
19
- await io.ask("test?");
20
- io.write("done");
21
- io.close?.();
22
- return { botToken: "tok", chatId: "123" };
23
- },
24
- createReadlineInterface: () => ({
25
- question: (_q: string, cb: (a: string) => void) => cb("answer"),
26
- close: () => {},
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 deps = createMockSetupDeps();
66
- const cli = new Cli(deps);
67
- await cli.setup();
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(mockInit).toHaveBeenCalled();
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 deps = createMockSetupDeps();
87
- deps.loadRawSettings = () => existing;
88
- deps.runSetupWizard = async (io, opts) => {
89
- receivedDefaults = opts?.defaults;
90
- io.close?.();
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("closes readline after wizard completes", async () => {
99
- const deps = createMockSetupDeps();
100
- const mockClose = mock(() => {});
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(mockClose).toHaveBeenCalled();
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(undefined, mockService({ install }));
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(undefined, mockService({ uninstall }));
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(undefined, mockService({ start }));
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(undefined, mockService({ stop }));
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(undefined, mockService({ update }));
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(undefined, mockService({ status }));
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(undefined, mockService({ logs }));
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(undefined, mockService({ logs }));
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(undefined, mockService());
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(undefined, mockService({ install: () => { throw new Error("Settings not found."); } }));
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
- let capturedCmd = "";
201
- let capturedOpts: any = {};
202
- const exec = mock((cmd: string, opts: object) => { capturedCmd = cmd; capturedOpts = opts; });
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(capturedCmd).toBe("claude --resume sess-123 --model opus");
212
- expect(capturedOpts.cwd).toBe("/tmp");
213
- expect(capturedOpts.stdio).toBe("inherit");
214
- expect(capturedOpts.env.CLAUDECODE).toBe("");
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
- let capturedCmd = "";
225
- const exec = mock((cmd: string, _opts: object) => { capturedCmd = cmd; });
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(capturedCmd).toBe("claude --model sonnet");
235
- });
236
-
237
- it("throws when settings are missing", () => {
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("returns null for non-object JSON", async () => {
274
- const dir = await import("node:fs").then(fs => {
275
- const d = `/tmp/macroclaw-test-${Date.now()}`;
276
- fs.mkdirSync(d, { recursive: true });
277
- fs.writeFileSync(`${d}/settings.json`, '"just a string"');
278
- return d;
279
- });
280
- expect(loadRawSettings(dir)).toBeNull();
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 { type: "json" };
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 {loadSettings, saveSettings} from "./settings";
11
- import {runSetupWizard} from "./setup";
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 #setupDeps: SetupDeps;
50
- readonly #systemService: SystemService;
51
-
52
- constructor(setupDeps?: Partial<SetupDeps>, systemService?: SystemService) {
53
- this.#setupDeps = { ...defaultSetupDeps(), ...setupDeps };
54
- this.#systemService = systemService ?? defaultSystemService();
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
- await this.#setupDeps.initLogger();
59
- const rl = this.#setupDeps.createReadlineInterface();
60
- const io = {
61
- ask: (question: string): Promise<string> =>
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(exec: (cmd: string, opts: object) => void = (cmd, opts) => execSync(cmd, opts)): void {
76
- const dir = this.#setupDeps.resolveDir();
77
- const settings = loadSettings(dir);
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
- exec(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
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.#systemService.install(token);
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.#systemService.uninstall();
45
+ this.#serviceManager.uninstall();
95
46
  console.log("Service uninstalled.");
96
47
  break;
97
48
  case "start": {
98
- const logCmd = this.#systemService.start();
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.#systemService.stop();
54
+ this.#serviceManager.stop();
104
55
  console.log("Service stopped.");
105
56
  break;
106
57
  case "update": {
107
- const logCmd = this.#systemService.update();
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.#systemService.status();
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.#systemService.logs(follow);
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
-