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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- run: () => defaultCli.setup().catch(handleError),
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", recurring: false }],
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("does not fire non-recurring jobs older than threshold", () => {
321
+ it("fires non-recurring job missed by more than 5 minutes", () => {
322
322
  writeScheduleConfig({
323
- jobs: [{ name: "old", cron: minutesAgoCron(10), prompt: "too late", recurring: false }],
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).not.toHaveBeenCalled();
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(1);
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 < MISSED_THRESHOLD) {
92
- // Non-recurring job missed (service was down) — fire with missed prefix
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
- let oauthToken: string | undefined;
120
- if (this.#platform === "darwin") {
121
- this.#io.write("\nmacOS requires a long-lived OAuth token for the service.\n");
122
- this.#io.write("Run `claude setup-token` in another terminal, then paste the token here.\n\n");
123
- oauthToken = await this.#io.ask("OAuth token: ");
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
- try {
131
- const svc = this.#serviceInstaller ?? new (await import("./system-service")).SystemServiceManager();
132
- const logCmd = svc.install(oauthToken);
133
- this.#io.write(`Service installed and started. Check logs:\n ${logCmd}\n`);
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;