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 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
- ## Setup
20
+ ## Quick Start
67
21
 
68
22
  ```bash
69
- # Run directly (no install needed)
70
- bunx macroclaw
71
-
72
- # Or install globally
73
- bun install -g macroclaw
74
- macroclaw
23
+ bunx macroclaw setup
75
24
  ```
76
25
 
77
- On first launch, an interactive setup wizard guides you through configuration.
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
- The setup wizard will:
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 loaded from the file. Environment variables override file settings (see `.env.example`).
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
- ## Usage
55
+ ## Commands
106
56
 
107
- Run inside a tmux session so it survives SSH disconnects:
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
- ```bash
110
- tmux new -s macroclaw # start session
111
- macroclaw # run the bot
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
- # Ctrl+B, D — detach (bot keeps running)
114
- # tmux attach -t macroclaw — reattach later
115
- # tmux kill-session -t macroclaw — stop everything
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("../src/index.ts");
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.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/index.ts",
21
- "dev": "bun run --watch src/index.ts",
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
@@ -133,4 +133,4 @@ export class Claude {
133
133
  completion,
134
134
  };
135
135
  }
136
- }
136
+ }
@@ -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
+ });