macroclaw 0.18.0 → 0.19.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.19.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/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;