macroclaw 0.12.0 → 0.14.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.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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";
4
- import type { SystemService } from "./service";
3
+ import { Cli, createReadlineIo, handleError } from "./cli";
4
+ import { SettingsManager } from "./settings";
5
+ import type { SetupWizard } from "./setup";
6
+ import type { SystemServiceManager } from "./system-service";
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,53 +67,49 @@ 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
 
111
- function mockService(overrides?: Partial<SystemService>): SystemService {
112
+ function mockService(overrides?: Record<string, unknown>): SystemServiceManager {
112
113
  return {
113
114
  install: mock(() => ""),
114
115
  uninstall: mock(() => {}),
@@ -118,73 +119,73 @@ function mockService(overrides?: Partial<SystemService>): SystemService {
118
119
  status: mock(() => ({ installed: false, running: false, platform: "systemd" as const })),
119
120
  logs: mock(() => "journalctl -u macroclaw -n 50 --no-pager"),
120
121
  ...overrides,
121
- };
122
+ } as unknown as SystemServiceManager;
122
123
  }
123
124
 
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");
228
+ expect(mockExecSync).toHaveBeenCalledWith(
229
+ "claude --model sonnet",
230
+ expect.objectContaining({ cwd: "/tmp", stdio: "inherit" }),
231
+ );
235
232
  });
236
233
 
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 }));
271
- });
272
-
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";
8
- import {ServiceManager, type SystemService} from "./service";
4
+ import pkg from "../package.json" with {type: "json"};
9
5
  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
- }
6
+ import {SettingsManager} from "./settings";
7
+ import {type SetupIo, SetupWizard} from "./setup";
8
+ import {SystemServiceManager} from "./system-service";
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: SystemServiceManager;
14
+
15
+ constructor(opts?: { wizard?: SetupWizard; settings?: SettingsManager; systemService?: SystemServiceManager }) {
16
+ this.#settingsManager = opts?.settings ?? new SettingsManager();
17
+ this.#setupWizard = opts?.wizard ?? new SetupWizard(createReadlineIo());
18
+ this.#serviceManager = opts?.systemService ?? new SystemServiceManager();
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
-
package/src/index.ts CHANGED
@@ -1,26 +1,19 @@
1
1
  import { cpSync, existsSync, readdirSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { App, type AppConfig } from "./app";
4
- import { createLogger, initLogger } from "./logger";
5
- import { applyEnvOverrides, loadSettings, printSettings } from "./settings";
4
+ import { configureLogger, createLogger } from "./logger";
5
+ import { SettingsManager } from "./settings";
6
6
  import { SpeechToText } from "./speech-to-text";
7
7
 
8
8
  export async function start(): Promise<void> {
9
9
  const log = createLogger("index");
10
10
 
11
- const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
11
+ const mgr = new SettingsManager();
12
+ const settings = mgr.load();
13
+ const { settings: resolved, overrides } = mgr.applyEnvOverrides(settings);
12
14
 
13
- const settings = loadSettings(defaultDir);
14
-
15
- if (!settings) {
16
- log.error("No settings found. Run `macroclaw setup` first.");
17
- process.exit(1);
18
- }
19
-
20
- const { settings: resolved, overrides } = applyEnvOverrides(settings);
21
-
22
- await initLogger({ level: resolved.logLevel, pinoramaUrl: resolved.pinoramaUrl });
23
- printSettings(resolved, overrides);
15
+ await configureLogger({ level: resolved.logLevel, pinoramaUrl: resolved.pinoramaUrl });
16
+ mgr.print(resolved, overrides);
24
17
 
25
18
  const workspace = resolve(resolved.workspace.replace(/^~/, process.env.HOME || "~"));
26
19
 
@@ -6,7 +6,7 @@ mock.module("pinorama-transport", () => ({
6
6
  default: mockPinoramaTransport,
7
7
  }));
8
8
 
9
- const { createLogger, initLogger } = await import("./logger");
9
+ const { createLogger, configureLogger } = await import("./logger");
10
10
 
11
11
  describe("createLogger", () => {
12
12
  it("returns a pino child logger with module field", () => {
@@ -16,29 +16,29 @@ describe("createLogger", () => {
16
16
  });
17
17
  });
18
18
 
19
- describe("initLogger", () => {
19
+ describe("configureLogger", () => {
20
20
  it("does nothing when called without opts", async () => {
21
21
  mockPinoramaTransport.mockClear();
22
- await initLogger();
22
+ await configureLogger();
23
23
  expect(mockPinoramaTransport).not.toHaveBeenCalled();
24
24
  });
25
25
 
26
26
  it("sets log level from opts", async () => {
27
27
  const log = createLogger("opts-level");
28
- await initLogger({ level: "warn" });
28
+ await configureLogger({ level: "warn" });
29
29
  expect(log.level).toBe("warn");
30
- await initLogger({ level: "info" }); // restore
30
+ await configureLogger({ level: "info" }); // restore
31
31
  });
32
32
 
33
33
  it("adds pinorama transport from opts", async () => {
34
34
  mockPinoramaTransport.mockClear();
35
- await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
35
+ await configureLogger({ pinoramaUrl: "http://example.com/pinorama" });
36
36
  expect(mockPinoramaTransport).toHaveBeenCalledWith({ url: "http://example.com/pinorama" });
37
37
  });
38
38
 
39
39
  it("does not add duplicate pinorama transport on second call", async () => {
40
40
  mockPinoramaTransport.mockClear();
41
- await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
41
+ await configureLogger({ pinoramaUrl: "http://example.com/pinorama" });
42
42
  expect(mockPinoramaTransport).not.toHaveBeenCalled();
43
43
  });
44
44
  });
package/src/logger.ts CHANGED
@@ -23,7 +23,7 @@ export interface LoggerOptions {
23
23
 
24
24
  let pinoramaAdded = false;
25
25
 
26
- export async function initLogger(opts?: LoggerOptions): Promise<void> {
26
+ export async function configureLogger(opts?: LoggerOptions): Promise<void> {
27
27
  if (opts?.level) logger.level = opts.level;
28
28
 
29
29
  if (opts?.pinoramaUrl && !pinoramaAdded) {