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/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/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
|
-
|
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 {
|
|
5
|
-
import {
|
|
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
|
|
11
|
+
const mgr = new SettingsManager();
|
|
12
|
+
const settings = mgr.load();
|
|
13
|
+
const { settings: resolved, overrides } = mgr.applyEnvOverrides(settings);
|
|
12
14
|
|
|
13
|
-
|
|
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
|
|
package/src/logger.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ mock.module("pinorama-transport", () => ({
|
|
|
6
6
|
default: mockPinoramaTransport,
|
|
7
7
|
}));
|
|
8
8
|
|
|
9
|
-
const { createLogger,
|
|
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("
|
|
19
|
+
describe("configureLogger", () => {
|
|
20
20
|
it("does nothing when called without opts", async () => {
|
|
21
21
|
mockPinoramaTransport.mockClear();
|
|
22
|
-
await
|
|
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
|
|
28
|
+
await configureLogger({ level: "warn" });
|
|
29
29
|
expect(log.level).toBe("warn");
|
|
30
|
-
await
|
|
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
|
|
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
|
|
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
|
|
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) {
|