macroclaw 0.3.0 → 0.5.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 +79 -73
- 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 +187 -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
|
@@ -5,52 +5,6 @@ Telegram-to-Claude-Code bridge. Bun + Grammy.
|
|
|
5
5
|
Uses the Claude Code CLI (`claude -p`) rather than the Agent SDK to avoid any possible
|
|
6
6
|
ToS issues with using a Claude subscription programmatically.
|
|
7
7
|
|
|
8
|
-
## Vision
|
|
9
|
-
|
|
10
|
-
Macroclaw is a minimal bridge between Telegram and Claude Code. It handles the parts
|
|
11
|
-
that a Claude session can't: receiving messages, managing processes, scheduling tasks,
|
|
12
|
-
and delivering responses.
|
|
13
|
-
|
|
14
|
-
Everything else — personality, memory, skills, behavior, conventions — lives in the
|
|
15
|
-
workspace. The platform stays small so the workspace can be infinitely customizable
|
|
16
|
-
without touching platform code.
|
|
17
|
-
|
|
18
|
-
## Architecture
|
|
19
|
-
|
|
20
|
-
Macroclaw follows a **thin platform, rich workspace** design:
|
|
21
|
-
|
|
22
|
-
**Platform** (this repo) — the runtime bridge:
|
|
23
|
-
- Telegram bot connection and message routing
|
|
24
|
-
- Claude Code process orchestration and session management
|
|
25
|
-
- Background agent spawning and lifecycle
|
|
26
|
-
- Cron scheduler (reads job definitions from workspace)
|
|
27
|
-
- Message queue (FIFO, serial processing)
|
|
28
|
-
- Timeout management and auto-retry
|
|
29
|
-
|
|
30
|
-
**Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
|
|
31
|
-
- [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
|
|
32
|
-
- [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
|
|
33
|
-
- [`.macroclaw/cron.json`](workspace-template/.macroclaw/cron.json) — scheduled job definitions
|
|
34
|
-
- [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
|
|
35
|
-
|
|
36
|
-
### Where does a new feature belong?
|
|
37
|
-
|
|
38
|
-
**Platform** when it:
|
|
39
|
-
- Requires external API access (Telegram, future integrations)
|
|
40
|
-
- Manages processes (spawning Claude, background agents, timeouts)
|
|
41
|
-
- Operates outside Claude sessions (cron scheduling, message queuing)
|
|
42
|
-
- Is a security boundary (chat authorization, workspace isolation)
|
|
43
|
-
- Is bootstrap logic (workspace initialization)
|
|
44
|
-
|
|
45
|
-
**Workspace** when it:
|
|
46
|
-
- Defines agent behavior or personality
|
|
47
|
-
- Is a convention Claude can follow via instructions
|
|
48
|
-
- Can be implemented as a skill
|
|
49
|
-
- Is data that Claude reads/writes (memory, tasks, cron definitions)
|
|
50
|
-
- Is a formatting or response style rule
|
|
51
|
-
|
|
52
|
-
> **Litmus test:** Could this feature work if you just wrote instructions in CLAUDE.md and/or created a skill? If yes → workspace. If no → platform.
|
|
53
|
-
|
|
54
8
|
## Security Model
|
|
55
9
|
|
|
56
10
|
Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
|
|
@@ -63,26 +17,22 @@ the bot.
|
|
|
63
17
|
- [Bun](https://bun.sh/) runtime
|
|
64
18
|
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in
|
|
65
19
|
|
|
66
|
-
##
|
|
20
|
+
## Quick Start
|
|
67
21
|
|
|
68
22
|
```bash
|
|
69
|
-
|
|
70
|
-
bunx macroclaw
|
|
71
|
-
|
|
72
|
-
# Or install globally
|
|
73
|
-
bun install -g macroclaw
|
|
74
|
-
macroclaw
|
|
23
|
+
bunx macroclaw setup
|
|
75
24
|
```
|
|
76
25
|
|
|
77
|
-
|
|
26
|
+
This runs the setup wizard, which:
|
|
27
|
+
1. Asks for your **Telegram bot token** (from [@BotFather](https://t.me/BotFather))
|
|
28
|
+
2. Starts the bot temporarily so you can send `/chatid` to discover your chat ID
|
|
29
|
+
3. Asks for your **chat ID**, **model** preference, **workspace path**, and optional **OpenAI API key**
|
|
30
|
+
4. Saves settings to `~/.macroclaw/settings.json`
|
|
31
|
+
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
32
|
|
|
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`
|
|
33
|
+
No separate install step needed — answering yes to the service prompt handles everything.
|
|
84
34
|
|
|
85
|
-
On subsequent runs, settings are
|
|
35
|
+
On subsequent runs, settings are pre-filled from the file.
|
|
86
36
|
|
|
87
37
|
### Configuration
|
|
88
38
|
|
|
@@ -102,36 +52,92 @@ Env vars take precedence over settings file values. On startup, a masked setting
|
|
|
102
52
|
|
|
103
53
|
Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
|
|
104
54
|
|
|
105
|
-
##
|
|
55
|
+
## Commands
|
|
106
56
|
|
|
107
|
-
|
|
57
|
+
| Command | Description |
|
|
58
|
+
|---------|-------------|
|
|
59
|
+
| `macroclaw start` | Start the bridge |
|
|
60
|
+
| `macroclaw setup` | Run the interactive setup wizard |
|
|
61
|
+
| `macroclaw claude` | Open Claude Code CLI in the main session |
|
|
62
|
+
| `macroclaw service install` | Install globally and register as a system service |
|
|
63
|
+
| `macroclaw service uninstall` | Stop and remove the system service |
|
|
64
|
+
| `macroclaw service start` | Start the system service |
|
|
65
|
+
| `macroclaw service stop` | Stop the system service |
|
|
66
|
+
| `macroclaw service update` | Reinstall latest version and restart |
|
|
108
67
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
macroclaw
|
|
68
|
+
### Running as a service
|
|
69
|
+
|
|
70
|
+
The recommended path is `bunx macroclaw setup` and answering yes to the service prompt (see [Quick Start](#quick-start)).
|
|
112
71
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
72
|
+
If macroclaw is already installed and configured, you can also install the service directly:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
macroclaw service install
|
|
116
76
|
```
|
|
117
77
|
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
118
82
|
## Development
|
|
119
83
|
|
|
120
84
|
```bash
|
|
121
85
|
git clone git@github.com:macrosak/macroclaw.git
|
|
122
86
|
cd macroclaw
|
|
123
|
-
cp .env.example .env # fill in real values
|
|
87
|
+
cp .env.example .env # fill in real values (see .env.example for available vars)
|
|
124
88
|
bun install --frozen-lockfile
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
## Development
|
|
128
89
|
|
|
129
|
-
```bash
|
|
130
90
|
bun run dev # start with watch mode
|
|
131
91
|
bun test # run tests (100% coverage enforced)
|
|
132
92
|
bun run claude # open Claude Code CLI in current main session
|
|
133
93
|
```
|
|
134
94
|
|
|
95
|
+
## Vision
|
|
96
|
+
|
|
97
|
+
Macroclaw is a minimal bridge between Telegram and Claude Code. It handles the parts
|
|
98
|
+
that a Claude session can't: receiving messages, managing processes, scheduling tasks,
|
|
99
|
+
and delivering responses.
|
|
100
|
+
|
|
101
|
+
Everything else — personality, memory, skills, behavior, conventions — lives in the
|
|
102
|
+
workspace. The platform stays small so the workspace can be infinitely customizable
|
|
103
|
+
without touching platform code.
|
|
104
|
+
|
|
105
|
+
## Architecture
|
|
106
|
+
|
|
107
|
+
Macroclaw follows a **thin platform, rich workspace** design:
|
|
108
|
+
|
|
109
|
+
**Platform** (this repo) — the runtime bridge:
|
|
110
|
+
- Telegram bot connection and message routing
|
|
111
|
+
- Claude Code process orchestration and session management
|
|
112
|
+
- Background agent spawning and lifecycle
|
|
113
|
+
- Cron scheduler (reads job definitions from workspace)
|
|
114
|
+
- Message queue (FIFO, serial processing)
|
|
115
|
+
- Timeout management and auto-retry
|
|
116
|
+
|
|
117
|
+
**Workspace** — the intelligence layer, initialized from [`workspace-template/`](workspace-template/):
|
|
118
|
+
- [`CLAUDE.md`](workspace-template/CLAUDE.md) — agent behavior, conventions, response style
|
|
119
|
+
- [`.claude/skills/`](workspace-template/.claude/skills/) — teachable capabilities
|
|
120
|
+
- [`.macroclaw/cron.json`](workspace-template/.macroclaw/cron.json) — scheduled job definitions
|
|
121
|
+
- [`MEMORY.md`](workspace-template/MEMORY.md) — persistent memory
|
|
122
|
+
|
|
123
|
+
### Where does a new feature belong?
|
|
124
|
+
|
|
125
|
+
**Platform** when it:
|
|
126
|
+
- Requires external API access (Telegram, future integrations)
|
|
127
|
+
- Manages processes (spawning Claude, background agents, timeouts)
|
|
128
|
+
- Operates outside Claude sessions (cron scheduling, message queuing)
|
|
129
|
+
- Is a security boundary (chat authorization, workspace isolation)
|
|
130
|
+
- Is bootstrap logic (workspace initialization)
|
|
131
|
+
|
|
132
|
+
**Workspace** when it:
|
|
133
|
+
- Defines agent behavior or personality
|
|
134
|
+
- Is a convention Claude can follow via instructions
|
|
135
|
+
- Can be implemented as a skill
|
|
136
|
+
- Is data that Claude reads/writes (memory, tasks, cron definitions)
|
|
137
|
+
- Is a formatting or response style rule
|
|
138
|
+
|
|
139
|
+
> **Litmus test:** Could this feature work if you just wrote instructions in CLAUDE.md and/or created a skill? If yes → workspace. If no → platform.
|
|
140
|
+
|
|
135
141
|
## License
|
|
136
142
|
|
|
137
143
|
MIT
|
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.5.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
|
+
});
|