macroclaw 0.2.0 → 0.4.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 +33 -27
- package/bin/macroclaw.js +3 -1
- package/package.json +4 -3
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +293 -0
- package/src/cli.ts +186 -0
- package/src/index.ts +31 -41
- package/src/logger.test.ts +18 -7
- package/src/logger.ts +17 -7
- package/src/service.test.ts +590 -0
- package/src/service.ts +346 -0
- package/src/setup.test.ts +160 -3
- package/src/setup.ts +63 -8
package/README.md
CHANGED
|
@@ -63,26 +63,22 @@ the bot.
|
|
|
63
63
|
- [Bun](https://bun.sh/) runtime
|
|
64
64
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in
|
|
65
65
|
|
|
66
|
-
##
|
|
66
|
+
## Quick Start
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
|
-
|
|
70
|
-
bunx macroclaw
|
|
71
|
-
|
|
72
|
-
# Or install globally
|
|
73
|
-
bun install -g macroclaw
|
|
74
|
-
macroclaw
|
|
69
|
+
bunx macroclaw setup
|
|
75
70
|
```
|
|
76
71
|
|
|
77
|
-
|
|
72
|
+
This runs the setup wizard, which:
|
|
73
|
+
1. Asks for your **Telegram bot token** (from [@BotFather](https://t.me/BotFather))
|
|
74
|
+
2. Starts the bot temporarily so you can send `/chatid` to discover your chat ID
|
|
75
|
+
3. Asks for your **chat ID**, **model** preference, **workspace path**, and optional **OpenAI API key**
|
|
76
|
+
4. Saves settings to `~/.macroclaw/settings.json`
|
|
77
|
+
5. Offers to install as a system service — this installs macroclaw globally (`bun install -g`), registers it as a **launchd** agent (macOS) or **systemd** unit (Linux), and starts the bridge automatically
|
|
78
78
|
|
|
79
|
-
|
|
80
|
-
1. Ask for your **Telegram bot token** (from [@BotFather](https://t.me/BotFather))
|
|
81
|
-
2. Start the bot temporarily so you can send `/chatid` to discover your chat ID
|
|
82
|
-
3. Ask for your **chat ID**, **model** preference, **workspace path**, and optional **OpenAI API key**
|
|
83
|
-
4. Save settings to `~/.macroclaw/settings.json`
|
|
79
|
+
No separate install step needed — answering yes to the service prompt handles everything.
|
|
84
80
|
|
|
85
|
-
On subsequent runs, settings are
|
|
81
|
+
On subsequent runs, settings are pre-filled from the file.
|
|
86
82
|
|
|
87
83
|
### Configuration
|
|
88
84
|
|
|
@@ -102,31 +98,41 @@ Env vars take precedence over settings file values. On startup, a masked setting
|
|
|
102
98
|
|
|
103
99
|
Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
|
|
104
100
|
|
|
105
|
-
##
|
|
101
|
+
## Commands
|
|
106
102
|
|
|
107
|
-
|
|
103
|
+
| Command | Description |
|
|
104
|
+
|---------|-------------|
|
|
105
|
+
| `macroclaw start` | Start the bridge |
|
|
106
|
+
| `macroclaw setup` | Run the interactive setup wizard |
|
|
107
|
+
| `macroclaw claude` | Open Claude Code CLI in the main session |
|
|
108
|
+
| `macroclaw service install` | Install globally and register as a system service |
|
|
109
|
+
| `macroclaw service uninstall` | Stop and remove the system service |
|
|
110
|
+
| `macroclaw service start` | Start the system service |
|
|
111
|
+
| `macroclaw service stop` | Stop the system service |
|
|
112
|
+
| `macroclaw service update` | Reinstall latest version and restart |
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
macroclaw
|
|
114
|
+
### Running as a service
|
|
115
|
+
|
|
116
|
+
The recommended path is `bunx macroclaw setup` and answering yes to the service prompt (see [Quick Start](#quick-start)).
|
|
112
117
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
If macroclaw is already installed and configured, you can also install the service directly:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
macroclaw service install
|
|
116
122
|
```
|
|
117
123
|
|
|
124
|
+
Both paths install macroclaw globally via `bun install -g`, register it as a **launchd** agent (macOS) or **systemd** unit (Linux) with auto-restart, and start the bridge.
|
|
125
|
+
|
|
126
|
+
On Linux, the command runs as a normal user. Only the privileged operations (writing to `/etc/systemd/system/`, systemctl commands) are elevated via `sudo`, which prompts for a password when needed. Package installation and path resolution stay in the user's environment.
|
|
127
|
+
|
|
118
128
|
## Development
|
|
119
129
|
|
|
120
130
|
```bash
|
|
121
131
|
git clone git@github.com:macrosak/macroclaw.git
|
|
122
132
|
cd macroclaw
|
|
123
|
-
cp .env.example .env # fill in real values
|
|
133
|
+
cp .env.example .env # fill in real values (see .env.example for available vars)
|
|
124
134
|
bun install --frozen-lockfile
|
|
125
|
-
```
|
|
126
135
|
|
|
127
|
-
## Development
|
|
128
|
-
|
|
129
|
-
```bash
|
|
130
136
|
bun run dev # start with watch mode
|
|
131
137
|
bun test # run tests (100% coverage enforced)
|
|
132
138
|
bun run claude # open Claude Code CLI in current main session
|
package/bin/macroclaw.js
CHANGED
|
@@ -3,4 +3,6 @@ if (typeof Bun === "undefined") {
|
|
|
3
3
|
console.error("macroclaw requires Bun. Install it: https://bun.sh");
|
|
4
4
|
process.exit(1);
|
|
5
5
|
}
|
|
6
|
-
await import("
|
|
6
|
+
const { runMain } = await import("citty");
|
|
7
|
+
const { main } = await import("../src/cli.ts");
|
|
8
|
+
await runMain(main);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "macroclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Telegram-to-Claude-Code bridge",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
"url": "https://github.com/macrosak/macroclaw"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"start": "bun run src/
|
|
21
|
-
"dev": "bun run --watch src/
|
|
20
|
+
"start": "bun run src/cli.ts",
|
|
21
|
+
"dev": "bun run --watch src/cli.ts",
|
|
22
22
|
"check": "tsc --noEmit && biome check && bun test",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
24
|
"lint": "biome check",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"claude": "set -a && . ./.env && set +a && cd ${WORKSPACE:-~/.macroclaw-workspace} && claude --resume $(node -p \"require('$HOME/.macroclaw/settings.json').sessionId\") --model ${MODEL:-sonnet}"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
+
"citty": "^0.2.1",
|
|
31
32
|
"cron-parser": "^5.5.0",
|
|
32
33
|
"grammy": "^1.39.3",
|
|
33
34
|
"openai": "^6.27.0",
|
package/src/claude.ts
CHANGED
package/src/cli.test.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
2
|
+
import { runCommand } from "citty";
|
|
3
|
+
import { Cli, handleError, loadRawSettings, type SetupDeps } from "./cli";
|
|
4
|
+
import type { SystemService } from "./service";
|
|
5
|
+
|
|
6
|
+
// Only mock ./index — safe since no other test imports it
|
|
7
|
+
const mockStart = mock(async () => {});
|
|
8
|
+
mock.module("./index", () => ({ start: mockStart }));
|
|
9
|
+
|
|
10
|
+
const { main } = await import("./cli");
|
|
11
|
+
|
|
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;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("CLI routing", () => {
|
|
35
|
+
it("requires a subcommand when no args given", async () => {
|
|
36
|
+
await expect(runCommand(main, { rawArgs: [] })).rejects.toThrow("No command specified");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("routes 'start' subcommand to start", async () => {
|
|
40
|
+
mockStart.mockClear();
|
|
41
|
+
await runCommand(main, { rawArgs: ["start"] });
|
|
42
|
+
expect(mockStart).toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("has all subcommands defined", () => {
|
|
46
|
+
expect(main.subCommands).toBeDefined();
|
|
47
|
+
const subs = main.subCommands as Record<string, unknown>;
|
|
48
|
+
expect(subs.start).toBeDefined();
|
|
49
|
+
expect(subs.setup).toBeDefined();
|
|
50
|
+
expect(subs.claude).toBeDefined();
|
|
51
|
+
expect(subs.service).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("has correct meta", () => {
|
|
55
|
+
expect(main.meta).toEqual({
|
|
56
|
+
name: "macroclaw",
|
|
57
|
+
description: "Telegram-to-Claude-Code bridge",
|
|
58
|
+
version: "0.0.0-dev",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("Cli.setup", () => {
|
|
64
|
+
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);
|
|
79
|
+
await cli.setup();
|
|
80
|
+
expect(mockInit).toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("passes existing settings as defaults to wizard", async () => {
|
|
84
|
+
const existing = { botToken: "old-tok", chatId: "999" };
|
|
85
|
+
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);
|
|
94
|
+
await cli.setup();
|
|
95
|
+
expect(receivedDefaults).toEqual(existing);
|
|
96
|
+
});
|
|
97
|
+
|
|
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);
|
|
106
|
+
await cli.setup();
|
|
107
|
+
expect(mockClose).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function mockService(overrides?: Partial<SystemService>): SystemService {
|
|
112
|
+
return {
|
|
113
|
+
install: mock(() => ""),
|
|
114
|
+
uninstall: mock(() => {}),
|
|
115
|
+
start: mock(() => ""),
|
|
116
|
+
stop: mock(() => {}),
|
|
117
|
+
update: mock(() => ""),
|
|
118
|
+
...overrides,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe("Cli.service", () => {
|
|
123
|
+
it("runs install action", () => {
|
|
124
|
+
const install = mock(() => "tail -f /logs");
|
|
125
|
+
const cli = new Cli(undefined, mockService({ install }));
|
|
126
|
+
cli.service("install");
|
|
127
|
+
expect(install).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("runs uninstall action", () => {
|
|
131
|
+
const uninstall = mock(() => {});
|
|
132
|
+
const cli = new Cli(undefined, mockService({ uninstall }));
|
|
133
|
+
cli.service("uninstall");
|
|
134
|
+
expect(uninstall).toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("runs start action", () => {
|
|
138
|
+
const start = mock(() => "tail -f /logs");
|
|
139
|
+
const cli = new Cli(undefined, mockService({ start }));
|
|
140
|
+
cli.service("start");
|
|
141
|
+
expect(start).toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("runs stop action", () => {
|
|
145
|
+
const stop = mock(() => {});
|
|
146
|
+
const cli = new Cli(undefined, mockService({ stop }));
|
|
147
|
+
cli.service("stop");
|
|
148
|
+
expect(stop).toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("runs update action", () => {
|
|
152
|
+
const update = mock(() => "tail -f /logs");
|
|
153
|
+
const cli = new Cli(undefined, mockService({ update }));
|
|
154
|
+
cli.service("update");
|
|
155
|
+
expect(update).toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("throws for unknown action", () => {
|
|
159
|
+
const cli = new Cli(undefined, mockService());
|
|
160
|
+
expect(() => cli.service("bogus")).toThrow("Unknown service action: bogus");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("throws service errors", () => {
|
|
164
|
+
const cli = new Cli(undefined, mockService({ install: () => { throw new Error("Settings not found."); } }));
|
|
165
|
+
expect(() => cli.service("install")).toThrow("Settings not found.");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("Cli.claude", () => {
|
|
170
|
+
it("builds claude command with session and model", async () => {
|
|
171
|
+
const fs = await import("node:fs");
|
|
172
|
+
const dir = `/tmp/macroclaw-test-claude-${Date.now()}`;
|
|
173
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
174
|
+
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123", model: "opus", workspace: "/tmp" }));
|
|
175
|
+
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({ mainSessionId: "sess-123" }));
|
|
176
|
+
|
|
177
|
+
let capturedCmd = "";
|
|
178
|
+
let capturedOpts: any = {};
|
|
179
|
+
const exec = mock((cmd: string, opts: object) => { capturedCmd = cmd; capturedOpts = opts; });
|
|
180
|
+
|
|
181
|
+
const deps = createMockSetupDeps();
|
|
182
|
+
deps.resolveDir = () => dir;
|
|
183
|
+
const cli = new Cli(deps);
|
|
184
|
+
cli.claude(exec);
|
|
185
|
+
|
|
186
|
+
fs.rmSync(dir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
expect(capturedCmd).toBe("claude --resume sess-123 --model opus");
|
|
189
|
+
expect(capturedOpts.cwd).toBe("/tmp");
|
|
190
|
+
expect(capturedOpts.stdio).toBe("inherit");
|
|
191
|
+
expect(capturedOpts.env.CLAUDECODE).toBe("");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("omits --resume when no session exists", async () => {
|
|
195
|
+
const fs = await import("node:fs");
|
|
196
|
+
const dir = `/tmp/macroclaw-test-claude-${Date.now()}`;
|
|
197
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
198
|
+
fs.writeFileSync(`${dir}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123", model: "sonnet", workspace: "/tmp" }));
|
|
199
|
+
fs.writeFileSync(`${dir}/sessions.json`, JSON.stringify({}));
|
|
200
|
+
|
|
201
|
+
let capturedCmd = "";
|
|
202
|
+
const exec = mock((cmd: string, _opts: object) => { capturedCmd = cmd; });
|
|
203
|
+
|
|
204
|
+
const deps = createMockSetupDeps();
|
|
205
|
+
deps.resolveDir = () => dir;
|
|
206
|
+
const cli = new Cli(deps);
|
|
207
|
+
cli.claude(exec);
|
|
208
|
+
|
|
209
|
+
fs.rmSync(dir, { recursive: true });
|
|
210
|
+
|
|
211
|
+
expect(capturedCmd).toBe("claude --model sonnet");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("throws when settings are missing", () => {
|
|
215
|
+
const deps = createMockSetupDeps();
|
|
216
|
+
deps.resolveDir = () => "/nonexistent/path";
|
|
217
|
+
const cli = new Cli(deps);
|
|
218
|
+
expect(() => cli.claude(mock())).toThrow("Settings not found");
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe("loadRawSettings", () => {
|
|
223
|
+
it("returns null when file does not exist", () => {
|
|
224
|
+
expect(loadRawSettings("/nonexistent/path")).toBeNull();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reads and parses valid settings file", async () => {
|
|
228
|
+
const dir = await import("node:fs").then(fs => {
|
|
229
|
+
const d = `/tmp/macroclaw-test-${Date.now()}`;
|
|
230
|
+
fs.mkdirSync(d, { recursive: true });
|
|
231
|
+
fs.writeFileSync(`${d}/settings.json`, JSON.stringify({ botToken: "tok", chatId: "123" }));
|
|
232
|
+
return d;
|
|
233
|
+
});
|
|
234
|
+
const result = loadRawSettings(dir);
|
|
235
|
+
expect(result).toEqual({ botToken: "tok", chatId: "123" });
|
|
236
|
+
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("returns null for invalid JSON", async () => {
|
|
240
|
+
const dir = await import("node:fs").then(fs => {
|
|
241
|
+
const d = `/tmp/macroclaw-test-${Date.now()}`;
|
|
242
|
+
fs.mkdirSync(d, { recursive: true });
|
|
243
|
+
fs.writeFileSync(`${d}/settings.json`, "not json");
|
|
244
|
+
return d;
|
|
245
|
+
});
|
|
246
|
+
expect(loadRawSettings(dir)).toBeNull();
|
|
247
|
+
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("returns null for non-object JSON", 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`, '"just a string"');
|
|
255
|
+
return d;
|
|
256
|
+
});
|
|
257
|
+
expect(loadRawSettings(dir)).toBeNull();
|
|
258
|
+
await import("node:fs").then(fs => fs.rmSync(dir, { recursive: true }));
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("handleError", () => {
|
|
263
|
+
it("prints error message and exits", () => {
|
|
264
|
+
const mockExit = mock((_code?: number) => { throw new Error("exit"); });
|
|
265
|
+
const mockConsoleError = mock((..._args: unknown[]) => {});
|
|
266
|
+
const origExit = process.exit;
|
|
267
|
+
const origError = console.error;
|
|
268
|
+
process.exit = mockExit as typeof process.exit;
|
|
269
|
+
console.error = mockConsoleError;
|
|
270
|
+
try {
|
|
271
|
+
handleError(new Error("Settings not found."));
|
|
272
|
+
} catch { /* exit throws */ }
|
|
273
|
+
process.exit = origExit;
|
|
274
|
+
console.error = origError;
|
|
275
|
+
expect(mockConsoleError).toHaveBeenCalledWith("Settings not found.");
|
|
276
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("handles non-Error values", () => {
|
|
280
|
+
const mockExit = mock((_code?: number) => { throw new Error("exit"); });
|
|
281
|
+
const mockConsoleError = mock((..._args: unknown[]) => {});
|
|
282
|
+
const origExit = process.exit;
|
|
283
|
+
const origError = console.error;
|
|
284
|
+
process.exit = mockExit as typeof process.exit;
|
|
285
|
+
console.error = mockConsoleError;
|
|
286
|
+
try {
|
|
287
|
+
handleError("string error");
|
|
288
|
+
} catch { /* exit throws */ }
|
|
289
|
+
process.exit = origExit;
|
|
290
|
+
console.error = origError;
|
|
291
|
+
expect(mockConsoleError).toHaveBeenCalledWith("string error");
|
|
292
|
+
});
|
|
293
|
+
});
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {execSync} from "node:child_process";
|
|
2
|
+
import {existsSync, readFileSync} from "node:fs";
|
|
3
|
+
import {join, resolve} from "node:path";
|
|
4
|
+
import {createInterface} from "node:readline";
|
|
5
|
+
import {defineCommand} from "citty";
|
|
6
|
+
import {initLogger} from "./logger";
|
|
7
|
+
import {ServiceManager, type SystemService} from "./service";
|
|
8
|
+
import {loadSessions} from "./sessions";
|
|
9
|
+
import {loadSettings, saveSettings} from "./settings";
|
|
10
|
+
import {runSetupWizard} from "./setup";
|
|
11
|
+
|
|
12
|
+
export interface SetupDeps {
|
|
13
|
+
initLogger: () => Promise<void>;
|
|
14
|
+
saveSettings: (settings: unknown, dir: string) => void;
|
|
15
|
+
loadRawSettings: (dir: string) => Record<string, unknown> | null;
|
|
16
|
+
runSetupWizard: (io: { ask: (q: string) => Promise<string>; write: (m: string) => void; close?: () => void }, opts?: { defaults?: Record<string, unknown>; onSettingsReady?: (settings: unknown) => void }) => Promise<unknown>;
|
|
17
|
+
createReadlineInterface: () => { question: (q: string, cb: (a: string) => void) => void; close: () => void };
|
|
18
|
+
resolveDir: () => string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function loadRawSettings(dir: string): Record<string, unknown> | null {
|
|
22
|
+
const path = join(dir, "settings.json");
|
|
23
|
+
if (!existsSync(path)) return null;
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
26
|
+
return typeof raw === "object" && raw !== null ? raw : null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function defaultSetupDeps(): SetupDeps {
|
|
33
|
+
return {
|
|
34
|
+
initLogger,
|
|
35
|
+
saveSettings: saveSettings as (settings: unknown, dir: string) => void,
|
|
36
|
+
loadRawSettings,
|
|
37
|
+
runSetupWizard: runSetupWizard as SetupDeps["runSetupWizard"],
|
|
38
|
+
createReadlineInterface: () => createInterface({ input: process.stdin, output: process.stdout }),
|
|
39
|
+
resolveDir: () => resolve(process.env.HOME || "~", ".macroclaw"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function defaultSystemService(): SystemService {
|
|
44
|
+
return new ServiceManager();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Cli {
|
|
48
|
+
readonly #setupDeps: SetupDeps;
|
|
49
|
+
readonly #systemService: SystemService;
|
|
50
|
+
|
|
51
|
+
constructor(setupDeps?: Partial<SetupDeps>, systemService?: SystemService) {
|
|
52
|
+
this.#setupDeps = { ...defaultSetupDeps(), ...setupDeps };
|
|
53
|
+
this.#systemService = systemService ?? defaultSystemService();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async setup(): Promise<void> {
|
|
57
|
+
await this.#setupDeps.initLogger();
|
|
58
|
+
const rl = this.#setupDeps.createReadlineInterface();
|
|
59
|
+
const io = {
|
|
60
|
+
ask: (question: string): Promise<string> =>
|
|
61
|
+
new Promise((res) => rl.question(question, (answer: string) => res(answer.trim()))),
|
|
62
|
+
write: (msg: string) => process.stdout.write(msg),
|
|
63
|
+
close: () => rl.close(),
|
|
64
|
+
};
|
|
65
|
+
const dir = this.#setupDeps.resolveDir();
|
|
66
|
+
const defaults = this.#setupDeps.loadRawSettings(dir) ?? undefined;
|
|
67
|
+
const settings = await this.#setupDeps.runSetupWizard(io, {
|
|
68
|
+
defaults,
|
|
69
|
+
onSettingsReady: (s) => this.#setupDeps.saveSettings(s, dir),
|
|
70
|
+
});
|
|
71
|
+
this.#setupDeps.saveSettings(settings, dir);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
claude(exec: (cmd: string, opts: object) => void = (cmd, opts) => execSync(cmd, opts)): void {
|
|
75
|
+
const dir = this.#setupDeps.resolveDir();
|
|
76
|
+
const settings = loadSettings(dir);
|
|
77
|
+
if (!settings) throw new Error("Settings not found. Run `macroclaw setup` first.");
|
|
78
|
+
const sessions = loadSessions(dir);
|
|
79
|
+
const args = ["claude"];
|
|
80
|
+
if (sessions.mainSessionId) args.push("--resume", sessions.mainSessionId);
|
|
81
|
+
args.push("--model", settings.model);
|
|
82
|
+
exec(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
service(action: string, token?: string): void {
|
|
86
|
+
switch (action) {
|
|
87
|
+
case "install": {
|
|
88
|
+
const logCmd = this.#systemService.install(token);
|
|
89
|
+
console.log(`Service installed and started. Check logs:\n ${logCmd}`);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
case "uninstall":
|
|
93
|
+
this.#systemService.uninstall();
|
|
94
|
+
console.log("Service uninstalled.");
|
|
95
|
+
break;
|
|
96
|
+
case "start": {
|
|
97
|
+
const logCmd = this.#systemService.start();
|
|
98
|
+
console.log(`Service started. Check logs:\n ${logCmd}`);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "stop":
|
|
102
|
+
this.#systemService.stop();
|
|
103
|
+
console.log("Service stopped.");
|
|
104
|
+
break;
|
|
105
|
+
case "update": {
|
|
106
|
+
const logCmd = this.#systemService.update();
|
|
107
|
+
console.log(`Service updated. Check logs:\n ${logCmd}`);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
default:
|
|
111
|
+
throw new Error(`Unknown service action: ${action}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function handleError(err: unknown): never {
|
|
117
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function runStart(): Promise<void> {
|
|
122
|
+
const { start } = await import("./index");
|
|
123
|
+
await start();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const defaultCli = new Cli();
|
|
127
|
+
|
|
128
|
+
const startCommand = defineCommand({
|
|
129
|
+
meta: { name: "start", description: "Start the macroclaw bridge" },
|
|
130
|
+
run: () => runStart().catch(handleError),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const setupCommand = defineCommand({
|
|
134
|
+
meta: { name: "setup", description: "Run the interactive setup wizard" },
|
|
135
|
+
run: () => defaultCli.setup().catch(handleError),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const claudeCommand = defineCommand({
|
|
139
|
+
meta: { name: "claude", description: "Open Claude Code CLI in the main session" },
|
|
140
|
+
run: () => { try { defaultCli.claude(); } catch (err) { handleError(err); } },
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const serviceInstallCommand = defineCommand({
|
|
144
|
+
meta: { name: "install", description: "Install and start macroclaw as a system service" },
|
|
145
|
+
args: {
|
|
146
|
+
token: { type: "string", description: "Claude OAuth token from `claude setup-token` (required on macOS)" },
|
|
147
|
+
},
|
|
148
|
+
run: ({ args }) => { try { defaultCli.service("install", args.token); } catch (err) { handleError(err); } },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const serviceUninstallCommand = defineCommand({
|
|
152
|
+
meta: { name: "uninstall", description: "Stop and remove the system service" },
|
|
153
|
+
run: () => { try { defaultCli.service("uninstall"); } catch (err) { handleError(err); } },
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const serviceStartCommand = defineCommand({
|
|
157
|
+
meta: { name: "start", description: "Start the system service" },
|
|
158
|
+
run: () => { try { defaultCli.service("start"); } catch (err) { handleError(err); } },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const serviceStopCommand = defineCommand({
|
|
162
|
+
meta: { name: "stop", description: "Stop the system service" },
|
|
163
|
+
run: () => { try { defaultCli.service("stop"); } catch (err) { handleError(err); } },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const serviceUpdateCommand = defineCommand({
|
|
167
|
+
meta: { name: "update", description: "Reinstall latest version and restart the service" },
|
|
168
|
+
run: () => { try { defaultCli.service("update"); } catch (err) { handleError(err); } },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const serviceCommand = defineCommand({
|
|
172
|
+
meta: { name: "service", description: "Manage macroclaw system service" },
|
|
173
|
+
subCommands: {
|
|
174
|
+
install: serviceInstallCommand,
|
|
175
|
+
uninstall: serviceUninstallCommand,
|
|
176
|
+
start: serviceStartCommand,
|
|
177
|
+
stop: serviceStopCommand,
|
|
178
|
+
update: serviceUpdateCommand,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
export const main = defineCommand({
|
|
183
|
+
meta: { name: "macroclaw", description: "Telegram-to-Claude-Code bridge", version: "0.0.0-dev" },
|
|
184
|
+
subCommands: { start: startCommand, setup: setupCommand, claude: claudeCommand, service: serviceCommand },
|
|
185
|
+
});
|
|
186
|
+
|