macroclaw 0.18.0 → 0.20.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 +40 -1
- package/package.json +1 -1
- package/src/cli.test.ts +19 -1
- package/src/cli.ts +11 -2
- package/src/cron.test.ts +10 -5
- package/src/cron.ts +2 -3
- package/src/setup.test.ts +25 -0
- package/src/setup.ts +30 -17
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A lightweight bridge that turns a Telegram chat into a personal AI assistant —
|
|
|
9
9
|
Macroclaw runs with `dangerouslySkipPermissions` enabled. This is intentional — the bot
|
|
10
10
|
is designed to run in an isolated environment (container or VM) where the workspace is
|
|
11
11
|
the entire world. The single authorized chat ID ensures only one user can interact with
|
|
12
|
-
the bot.
|
|
12
|
+
the bot. See [Docker](#docker) for the recommended containerized setup.
|
|
13
13
|
|
|
14
14
|
## Requirements
|
|
15
15
|
|
|
@@ -55,6 +55,8 @@ Session state (Claude session IDs) is stored separately in `~/.macroclaw/session
|
|
|
55
55
|
|
|
56
56
|
## Commands
|
|
57
57
|
|
|
58
|
+
Run `macroclaw --help` or `macroclaw <command> --help` for the complete reference.
|
|
59
|
+
|
|
58
60
|
| Command | Description |
|
|
59
61
|
|---------|-------------|
|
|
60
62
|
| `macroclaw start` | Start the bridge |
|
|
@@ -80,6 +82,43 @@ Both paths install macroclaw globally via `bun install -g`, register it as a **l
|
|
|
80
82
|
|
|
81
83
|
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.
|
|
82
84
|
|
|
85
|
+
## Docker
|
|
86
|
+
|
|
87
|
+
Run macroclaw in a Docker container. The workspace is bind-mounted from the host; everything else (settings, Claude auth, sessions) lives in a named Docker volume.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
# 1. Login to Claude Code (one-time, interactive — use /login inside the session)
|
|
91
|
+
docker compose run --rm -w /workspace --entrypoint claude macroclaw
|
|
92
|
+
|
|
93
|
+
# 2. Setup macroclaw (one-time, interactive — bot token, chat ID, model)
|
|
94
|
+
docker compose run --rm macroclaw setup --skip-service
|
|
95
|
+
|
|
96
|
+
# 3. Start the bridge
|
|
97
|
+
docker compose up -d
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The `WORKSPACE` env var is pre-set in `docker-compose.yml` so you don't need to configure the workspace path during setup. The `--skip-service` flag skips the service installation prompt (not applicable in Docker).
|
|
101
|
+
|
|
102
|
+
To build a specific version:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
docker compose build --build-arg VERSION=0.18.0
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To reset everything (remove containers, volumes, and images):
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
docker compose down -v --rmi all
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Development with Docker
|
|
115
|
+
|
|
116
|
+
Use `Dockerfile.dev` to build from local source instead of the published npm package:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
docker compose -f docker-compose.dev.yml up -d
|
|
120
|
+
```
|
|
121
|
+
|
|
83
122
|
## Development
|
|
84
123
|
|
|
85
124
|
```bash
|
package/package.json
CHANGED
package/src/cli.test.ts
CHANGED
|
@@ -18,10 +18,11 @@ mock.module("node:child_process", () => ({
|
|
|
18
18
|
|
|
19
19
|
const { main } = await import("./cli");
|
|
20
20
|
|
|
21
|
-
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void> }) {
|
|
21
|
+
function createMockWizard(overrides?: { collectSettings?: (defaults?: Record<string, unknown>) => Promise<unknown>; installService?: () => Promise<void>; forceInstallService?: () => Promise<void> }) {
|
|
22
22
|
return {
|
|
23
23
|
collectSettings: overrides?.collectSettings ?? mock(async () => ({ botToken: "tok", chatId: "123" })),
|
|
24
24
|
installService: overrides?.installService ?? mock(async () => {}),
|
|
25
|
+
forceInstallService: overrides?.forceInstallService ?? mock(async () => {}),
|
|
25
26
|
} as unknown as SetupWizard;
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -94,6 +95,23 @@ describe("Cli.setup", () => {
|
|
|
94
95
|
expect((wizard.installService as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
95
96
|
});
|
|
96
97
|
|
|
98
|
+
it("skips service install when --skip-service is set", async () => {
|
|
99
|
+
const wizard = createMockWizard();
|
|
100
|
+
const cli = new Cli({ wizard, settings: createMockSettings() });
|
|
101
|
+
await cli.setup({ skipService: true });
|
|
102
|
+
expect((wizard.collectSettings as ReturnType<typeof mock>)).toHaveBeenCalled();
|
|
103
|
+
expect((wizard.installService as ReturnType<typeof mock>)).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("force installs service when --install-service is set", async () => {
|
|
107
|
+
const forceInstallService = mock(async () => {});
|
|
108
|
+
const wizard = { ...createMockWizard(), forceInstallService } as unknown as SetupWizard;
|
|
109
|
+
const cli = new Cli({ wizard, settings: createMockSettings() });
|
|
110
|
+
await cli.setup({ installService: true });
|
|
111
|
+
expect(forceInstallService).toHaveBeenCalled();
|
|
112
|
+
expect((wizard.installService as ReturnType<typeof mock>)).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
97
115
|
it("creates default wizard when none provided", () => {
|
|
98
116
|
const cli = new Cli({ settings: createMockSettings() });
|
|
99
117
|
expect(cli).toBeDefined();
|
package/src/cli.ts
CHANGED
|
@@ -18,10 +18,15 @@ export class Cli {
|
|
|
18
18
|
this.#serviceManager = opts?.systemService ?? new SystemServiceManager();
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
async setup(): Promise<void> {
|
|
21
|
+
async setup(opts?: { skipService?: boolean; installService?: boolean }): Promise<void> {
|
|
22
22
|
const defaults = this.#settingsManager.loadRaw() ?? undefined;
|
|
23
23
|
const settings = await this.#setupWizard.collectSettings(defaults);
|
|
24
24
|
this.#settingsManager.save(settings);
|
|
25
|
+
if (opts?.skipService) return;
|
|
26
|
+
if (opts?.installService) {
|
|
27
|
+
await this.#setupWizard.forceInstallService();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
25
30
|
await this.#setupWizard.installService();
|
|
26
31
|
}
|
|
27
32
|
|
|
@@ -123,7 +128,11 @@ const startCommand = defineCommand({
|
|
|
123
128
|
|
|
124
129
|
const setupCommand = defineCommand({
|
|
125
130
|
meta: { name: "setup", description: "Run the interactive setup wizard" },
|
|
126
|
-
|
|
131
|
+
args: {
|
|
132
|
+
"skip-service": { type: "boolean", description: "Skip the service installation prompt" },
|
|
133
|
+
"install-service": { type: "boolean", description: "Install as a system service without prompting" },
|
|
134
|
+
},
|
|
135
|
+
run: ({ args }) => defaultCli.setup({ skipService: args["skip-service"], installService: args["install-service"] }).catch(handleError),
|
|
127
136
|
});
|
|
128
137
|
|
|
129
138
|
const claudeCommand = defineCommand({
|
package/src/cron.test.ts
CHANGED
|
@@ -254,7 +254,7 @@ describe("CronScheduler", () => {
|
|
|
254
254
|
|
|
255
255
|
it("does not write file when no non-recurring jobs fired", () => {
|
|
256
256
|
writeScheduleConfig({
|
|
257
|
-
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope"
|
|
257
|
+
jobs: [{ name: "recurring", cron: nonMatchingCron(), prompt: "nope" }],
|
|
258
258
|
});
|
|
259
259
|
|
|
260
260
|
const onJob = makeOnJob();
|
|
@@ -318,9 +318,9 @@ describe("missed non-recurring events", () => {
|
|
|
318
318
|
expect(onJob).not.toHaveBeenCalled();
|
|
319
319
|
});
|
|
320
320
|
|
|
321
|
-
it("
|
|
321
|
+
it("fires non-recurring job missed by more than 5 minutes", () => {
|
|
322
322
|
writeScheduleConfig({
|
|
323
|
-
jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "
|
|
323
|
+
jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "still fires", recurring: false }],
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
const onJob = makeOnJob();
|
|
@@ -328,8 +328,13 @@ describe("missed non-recurring events", () => {
|
|
|
328
328
|
cron.start();
|
|
329
329
|
cron.stop();
|
|
330
330
|
|
|
331
|
-
expect(onJob).
|
|
331
|
+
expect(onJob).toHaveBeenCalledTimes(1);
|
|
332
|
+
const call = onJob.mock.calls[0];
|
|
333
|
+
expect(call[0]).toBe("old");
|
|
334
|
+
expect(call[1]).toContain("[missed event, should have fired");
|
|
335
|
+
expect(call[1]).toContain("still fires");
|
|
336
|
+
|
|
332
337
|
const updated = readScheduleConfig();
|
|
333
|
-
expect(updated.jobs).toHaveLength(
|
|
338
|
+
expect(updated.jobs).toHaveLength(0);
|
|
334
339
|
});
|
|
335
340
|
});
|
package/src/cron.ts
CHANGED
|
@@ -25,7 +25,6 @@ export interface CronSchedulerConfig {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
28
|
-
const MISSED_THRESHOLD = 300_000; // 5 minutes
|
|
29
28
|
|
|
30
29
|
export class CronScheduler {
|
|
31
30
|
#lastMinute = -1;
|
|
@@ -88,8 +87,8 @@ export class CronScheduler {
|
|
|
88
87
|
if (job.recurring === false) {
|
|
89
88
|
firedNonRecurring.push(i);
|
|
90
89
|
}
|
|
91
|
-
} else if (job.recurring === false && diff
|
|
92
|
-
// Non-recurring job
|
|
90
|
+
} else if (job.recurring === false && diff >= 60_000) {
|
|
91
|
+
// Non-recurring job in the past — fire regardless of how late
|
|
93
92
|
const missedMinutes = Math.round(diff / 60_000);
|
|
94
93
|
const firedAt = prev.toISOString();
|
|
95
94
|
const missedPrompt = `[missed event, should have fired ${missedMinutes} min ago at ${firedAt}] ${job.prompt}`;
|
package/src/setup.test.ts
CHANGED
|
@@ -383,6 +383,31 @@ it("installs service when user answers yes", async () => {
|
|
|
383
383
|
expect(io.written).toContainEqual(expect.stringContaining("Service installation failed: Permission denied"));
|
|
384
384
|
});
|
|
385
385
|
|
|
386
|
+
it("forceInstallService installs without prompting", async () => {
|
|
387
|
+
mockInstall.mockClear();
|
|
388
|
+
const installer = createMockServiceInstaller();
|
|
389
|
+
const io = createMockIO([
|
|
390
|
+
"sk-test-token", // oauth token (macOS)
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
const wizard = new SetupWizard(io, { serviceInstaller: installer, platform: "darwin" });
|
|
394
|
+
await wizard.forceInstallService();
|
|
395
|
+
|
|
396
|
+
expect(mockInstall).toHaveBeenCalled();
|
|
397
|
+
expect(io.written).toContainEqual(expect.stringContaining("Service installed and started."));
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("forceInstallService skips on Linux without prompting", async () => {
|
|
401
|
+
mockInstall.mockClear();
|
|
402
|
+
const installer = createMockServiceInstaller();
|
|
403
|
+
const io = createMockIO([]);
|
|
404
|
+
|
|
405
|
+
const wizard = new SetupWizard(io, { serviceInstaller: installer, platform: "linux" });
|
|
406
|
+
await wizard.forceInstallService();
|
|
407
|
+
|
|
408
|
+
expect(mockInstall).toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
|
|
386
411
|
it("fails fast when claude CLI is not found", async () => {
|
|
387
412
|
mockExecSync.mockImplementation(() => { throw new Error("not found"); });
|
|
388
413
|
const io = createMockIO([]);
|
package/src/setup.ts
CHANGED
|
@@ -116,29 +116,42 @@ export class SetupWizard {
|
|
|
116
116
|
const installAnswer = await this.#io.ask("Install as a system service? [Y/n]: ");
|
|
117
117
|
if (installAnswer.toLowerCase() === "n" || installAnswer.toLowerCase() === "no") return;
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
if (!oauthToken) {
|
|
125
|
-
this.#io.write("No token provided. Skipping service installation.\n");
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
119
|
+
await this.#doInstallService();
|
|
120
|
+
} finally {
|
|
121
|
+
this.#io.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
129
124
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} catch (err) {
|
|
135
|
-
this.#io.write(`Service installation failed: ${(err as Error).message}\n`);
|
|
136
|
-
}
|
|
125
|
+
async forceInstallService(): Promise<void> {
|
|
126
|
+
this.#io.open();
|
|
127
|
+
try {
|
|
128
|
+
await this.#doInstallService();
|
|
137
129
|
} finally {
|
|
138
130
|
this.#io.close();
|
|
139
131
|
}
|
|
140
132
|
}
|
|
141
133
|
|
|
134
|
+
async #doInstallService(): Promise<void> {
|
|
135
|
+
let oauthToken: string | undefined;
|
|
136
|
+
if (this.#platform === "darwin") {
|
|
137
|
+
this.#io.write("\nmacOS requires a long-lived OAuth token for the service.\n");
|
|
138
|
+
this.#io.write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
|
|
139
|
+
oauthToken = await this.#io.ask("OAuth token: ");
|
|
140
|
+
if (!oauthToken) {
|
|
141
|
+
this.#io.write("No token provided. Skipping service installation.\n");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const svc = this.#serviceInstaller ?? new (await import("./system-service")).SystemServiceManager();
|
|
148
|
+
const logCmd = svc.install(oauthToken);
|
|
149
|
+
this.#io.write(`Service installed and started. Check logs:\n ${logCmd}\n`);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.#io.write(`Service installation failed: ${(err as Error).message}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
async #askValidated(field: SetupField, prompt: string, fallback: string): Promise<string> {
|
|
143
156
|
const schema = settingsSchema.shape[field];
|
|
144
157
|
let value = await this.#io.ask(prompt) || fallback;
|