macroclaw 0.1.0 → 0.3.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
@@ -63,21 +63,44 @@ the bot.
63
63
  - [Bun](https://bun.sh/) runtime
64
64
  - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in
65
65
 
66
- ## Install
66
+ ## Setup
67
67
 
68
68
  ```bash
69
69
  # Run directly (no install needed)
70
- TELEGRAM_BOT_TOKEN=xxx AUTHORIZED_CHAT_ID=123 bunx macroclaw
70
+ bunx macroclaw
71
71
 
72
72
  # Or install globally
73
73
  bun install -g macroclaw
74
- export TELEGRAM_BOT_TOKEN=xxx
75
- export AUTHORIZED_CHAT_ID=123
76
74
  macroclaw
77
75
  ```
78
76
 
79
- On first run, a workspace is created at `~/.macroclaw-workspace` with default config.
80
- Set `WORKSPACE` to use a different path. Set `MODEL` to override the Claude model.
77
+ On first launch, an interactive setup wizard guides you through configuration.
78
+
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`
84
+
85
+ On subsequent runs, settings are loaded from the file. Environment variables override file settings (see `.env.example`).
86
+
87
+ ### Configuration
88
+
89
+ Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
90
+
91
+ | Setting | Env var override | Default | Required |
92
+ |----------------|------------------------|----------------------------|----------|
93
+ | `botToken` | `TELEGRAM_BOT_TOKEN` | — | Yes |
94
+ | `chatId` | `AUTHORIZED_CHAT_ID` | — | Yes |
95
+ | `model` | `MODEL` | `sonnet` | No |
96
+ | `workspace` | `WORKSPACE` | `~/.macroclaw-workspace` | No |
97
+ | `openaiApiKey` | `OPENAI_API_KEY` | — | No |
98
+ | `logLevel` | `LOG_LEVEL` | `debug` | No |
99
+ | `pinoramaUrl` | `PINORAMA_URL` | — | No |
100
+
101
+ Env vars take precedence over settings file values. On startup, a masked settings summary is printed showing which values were overridden by env vars.
102
+
103
+ Session state (Claude session IDs) is stored separately in `~/.macroclaw/sessions.json`.
81
104
 
82
105
  ## Usage
83
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/app.test.ts CHANGED
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { App, type AppConfig } from "./app";
4
4
  import type { Claude, ClaudeDeferredResult, ClaudeResult, ClaudeRunOptions } from "./claude";
5
- import { saveSettings } from "./settings";
5
+ import { saveSessions } from "./sessions";
6
6
 
7
7
  const mockOpenAICreate = mock(async () => ({ text: "transcribed text" }));
8
8
 
@@ -64,7 +64,7 @@ const savedOpenAIKey = process.env.OPENAI_API_KEY;
64
64
  beforeEach(() => {
65
65
  process.env.OPENAI_API_KEY = "test-key";
66
66
  if (existsSync(tmpSettingsDir)) rmSync(tmpSettingsDir, { recursive: true });
67
- saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
67
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
68
68
  });
69
69
 
70
70
  afterEach(() => {
package/src/index.ts CHANGED
@@ -1,22 +1,38 @@
1
1
  import { cpSync, existsSync, readdirSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ import { createInterface } from "node:readline";
3
4
  import { App, type AppConfig } from "./app";
4
5
  import { createLogger, initLogger } from "./logger";
6
+ import { applyEnvOverrides, loadSettings, printSettings, saveSettings } from "./settings";
7
+ import { runSetupWizard } from "./setup";
5
8
 
6
9
  await initLogger();
7
10
  const log = createLogger("index");
8
11
 
9
- function requireEnv(name: string): string {
10
- const value = process.env[name];
11
- if (!value) {
12
- log.error({ name }, "Missing environment variable");
13
- process.exit(1);
14
- }
15
- return value;
12
+ const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
13
+
14
+ let settings = loadSettings(defaultDir);
15
+
16
+ if (!settings) {
17
+ log.info("No settings.json found, starting setup wizard");
18
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
19
+ const io = {
20
+ ask: (question: string): Promise<string> =>
21
+ new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim()))),
22
+ write: (msg: string) => process.stdout.write(msg),
23
+ };
24
+ settings = await runSetupWizard(io);
25
+ rl.close();
26
+ saveSettings(settings, defaultDir);
27
+ log.info("Settings saved");
16
28
  }
17
29
 
18
- const defaultWorkspace = resolve(process.env.HOME || "~", ".macroclaw-workspace");
19
- const workspace = process.env.WORKSPACE || defaultWorkspace;
30
+ const { settings: resolved, overrides } = applyEnvOverrides(settings);
31
+ settings = resolved;
32
+
33
+ printSettings(settings, overrides);
34
+
35
+ const workspace = resolve(settings.workspace.replace(/^~/, process.env.HOME || "~"));
20
36
 
21
37
  function initWorkspace(workspace: string) {
22
38
  const templateDir = join(dirname(import.meta.dir), "workspace-template");
@@ -33,10 +49,10 @@ function initWorkspace(workspace: string) {
33
49
  initWorkspace(workspace);
34
50
 
35
51
  const config: AppConfig = {
36
- botToken: requireEnv("TELEGRAM_BOT_TOKEN"),
37
- authorizedChatId: requireEnv("AUTHORIZED_CHAT_ID"),
52
+ botToken: settings.botToken,
53
+ authorizedChatId: settings.chatId,
38
54
  workspace,
39
- model: process.env.MODEL,
55
+ model: settings.model,
40
56
  };
41
57
 
42
58
  new App(config).start();
package/src/logger.ts CHANGED
@@ -6,12 +6,12 @@ const prettyStream = pretty({
6
6
  messageFormat: "[{module}] {msg}",
7
7
  });
8
8
 
9
- const level = (process.env.LOG_LEVEL || "debug") as pino.Level;
9
+ const level = (process.env.LOG_LEVEL || "info") as pino.Level;
10
10
 
11
11
  const streams: pino.StreamEntry[] = [{ level, stream: prettyStream }];
12
12
 
13
13
  const logger = pino(
14
- { level: process.env.LOG_LEVEL || "debug" },
14
+ { level: process.env.LOG_LEVEL || "info" },
15
15
  pino.multistream(streams),
16
16
  );
17
17
 
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, rmSync } from "node:fs";
3
3
  import { type Claude, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, type ClaudeRunOptions } from "./claude";
4
4
  import { Orchestrator, type OrchestratorConfig, type OrchestratorResponse } from "./orchestrator";
5
- import { saveSettings } from "./settings";
5
+ import { saveSessions } from "./sessions";
6
6
 
7
7
  const tmpSettingsDir = "/tmp/macroclaw-test-orchestrator-settings";
8
8
  const TEST_WORKSPACE = "/tmp/macroclaw-test-workspace";
@@ -231,7 +231,7 @@ describe("Orchestrator", () => {
231
231
 
232
232
  describe("session management", () => {
233
233
  it("uses --resume for existing session", async () => {
234
- saveSettings({ sessionId: "existing-session" }, tmpSettingsDir);
234
+ saveSessions({ mainSessionId: "existing-session" }, tmpSettingsDir);
235
235
  const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }));
236
236
  const { orch } = makeOrchestrator(claude);
237
237
 
@@ -254,7 +254,7 @@ describe("Orchestrator", () => {
254
254
  });
255
255
 
256
256
  it("creates new session when resume fails", async () => {
257
- saveSettings({ sessionId: "old-session" }, tmpSettingsDir);
257
+ saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
258
258
  let callCount = 0;
259
259
  const claude = mockClaude(async (_opts: ClaudeRunOptions): Promise<ClaudeResult> => {
260
260
  callCount++;
@@ -286,7 +286,7 @@ describe("Orchestrator", () => {
286
286
  });
287
287
 
288
288
  it("handleSessionCommand sends session via onResponse", async () => {
289
- saveSettings({ sessionId: "test-id" }, tmpSettingsDir);
289
+ saveSessions({ mainSessionId: "test-id" }, tmpSettingsDir);
290
290
  const claude = mockClaude(successResult({ action: "send", message: "", actionReason: "" }));
291
291
  const { orch, responses } = makeOrchestrator(claude);
292
292
 
@@ -298,7 +298,7 @@ describe("Orchestrator", () => {
298
298
  });
299
299
 
300
300
  it("background-agent forks from main session without affecting it", async () => {
301
- saveSettings({ sessionId: "main-session" }, tmpSettingsDir);
301
+ saveSessions({ mainSessionId: "main-session" }, tmpSettingsDir);
302
302
  const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }, "main-session"));
303
303
  const { orch } = makeOrchestrator(claude);
304
304
 
@@ -319,7 +319,7 @@ describe("Orchestrator", () => {
319
319
  });
320
320
 
321
321
  it("updates session ID after forked call", async () => {
322
- saveSettings({ sessionId: "old-session" }, tmpSettingsDir);
322
+ saveSessions({ mainSessionId: "old-session" }, tmpSettingsDir);
323
323
  const claude = mockClaude(successResult({ action: "send", message: "ok", actionReason: "ok" }, "new-forked-session"));
324
324
  const { orch } = makeOrchestrator(claude);
325
325
 
@@ -436,7 +436,7 @@ describe("Orchestrator", () => {
436
436
  });
437
437
 
438
438
  it("deferred → sends 'taking longer' via onResponse, feeds result back when resolved", async () => {
439
- saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
439
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
440
440
  let resolveCompletion: (r: ClaudeResult) => void;
441
441
  const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
442
442
  const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
@@ -458,7 +458,7 @@ describe("Orchestrator", () => {
458
458
  });
459
459
 
460
460
  it("session fork when background agent running on main session", async () => {
461
- saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
461
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
462
462
  let resolveCompletion: (r: ClaudeResult) => void;
463
463
  const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
464
464
  let callCount = 0;
@@ -485,7 +485,7 @@ describe("Orchestrator", () => {
485
485
  });
486
486
 
487
487
  it("background result with matching session: applied directly (no extra Claude call)", async () => {
488
- saveSettings({ sessionId: "test-session" }, tmpSettingsDir);
488
+ saveSessions({ mainSessionId: "test-session" }, tmpSettingsDir);
489
489
  // Use a deferred claude to simulate the scenario where a task is backgrounded
490
490
  let resolveCompletion: (r: ClaudeResult) => void;
491
491
  const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
@@ -587,7 +587,7 @@ describe("Orchestrator", () => {
587
587
  });
588
588
 
589
589
  it("adopt feeds result back when deferred resolves", async () => {
590
- saveSettings({ sessionId: "adopted-session" }, tmpSettingsDir);
590
+ saveSessions({ mainSessionId: "adopted-session" }, tmpSettingsDir);
591
591
  let resolveCompletion: (r: ClaudeResult) => void;
592
592
  const completion = new Promise<ClaudeResult>((r) => { resolveCompletion = r; });
593
593
  const claude = mockClaude(async (): Promise<ClaudeResult | ClaudeDeferredResult> =>
@@ -4,7 +4,7 @@ import { logPrompt, logResult } from "./history";
4
4
  import { createLogger } from "./logger";
5
5
  import { BG_TIMEOUT, CRON_TIMEOUT, MAIN_TIMEOUT, SYSTEM_PROMPT } from "./prompts";
6
6
  import { Queue } from "./queue";
7
- import { loadSettings, newSessionId, type Settings, saveSettings } from "./settings";
7
+ import { loadSessions, newSessionId, type Sessions, saveSessions } from "./sessions";
8
8
 
9
9
  const log = createLogger("orchestrator");
10
10
 
@@ -84,7 +84,7 @@ interface CallResult {
84
84
 
85
85
  export class Orchestrator {
86
86
  #claude: Claude;
87
- #settings: Settings;
87
+ #sessions: Sessions;
88
88
  #sessionId: string;
89
89
  #sessionFlag: "--resume" | "--session-id";
90
90
  #sessionResolved = false;
@@ -95,17 +95,17 @@ export class Orchestrator {
95
95
  constructor(config: OrchestratorConfig) {
96
96
  this.#config = config;
97
97
  this.#claude = config.claude ?? new Claude({ workspace: config.workspace, jsonSchema });
98
- this.#settings = loadSettings(config.settingsDir);
98
+ this.#sessions = loadSessions(config.settingsDir);
99
99
  this.#queue = new Queue<OrchestratorRequest>();
100
100
  this.#queue.setHandler((request) => this.#handleRequest(request));
101
101
 
102
- if (this.#settings.sessionId) {
103
- this.#sessionId = this.#settings.sessionId;
102
+ if (this.#sessions.mainSessionId) {
103
+ this.#sessionId = this.#sessions.mainSessionId;
104
104
  this.#sessionFlag = "--resume";
105
105
  } else {
106
106
  this.#sessionId = newSessionId();
107
107
  this.#sessionFlag = "--session-id";
108
- saveSettings({ sessionId: this.#sessionId }, config.settingsDir);
108
+ saveSessions({ mainSessionId: this.#sessionId }, config.settingsDir);
109
109
  log.info({ sessionId: this.#sessionId }, "Created new session");
110
110
  }
111
111
  }
@@ -272,7 +272,7 @@ export class Orchestrator {
272
272
  this.#sessionId = newSessionId();
273
273
  log.info({ sessionId: this.#sessionId }, "Resume failed, created new session");
274
274
  this.#sessionFlag = "--session-id";
275
- saveSettings({ sessionId: this.#sessionId }, this.#config.settingsDir);
275
+ saveSessions({ mainSessionId: this.#sessionId }, this.#config.settingsDir);
276
276
  result = await this.#callClaude(built, this.#sessionFlag, this.#sessionId);
277
277
  }
278
278
 
@@ -282,7 +282,7 @@ export class Orchestrator {
282
282
  if (result.sessionId && result.sessionId !== this.#sessionId) {
283
283
  log.info({ oldSessionId: this.#sessionId, newSessionId: result.sessionId }, "Session forked, updating session ID");
284
284
  this.#sessionId = result.sessionId;
285
- saveSettings({ sessionId: this.#sessionId }, this.#config.settingsDir);
285
+ saveSessions({ mainSessionId: this.#sessionId }, this.#config.settingsDir);
286
286
  }
287
287
 
288
288
  // Mark resolved on first success
@@ -0,0 +1,62 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadSessions, newSessionId, saveSessions } from "./sessions";
5
+
6
+ const tmpDir = "/tmp/macroclaw-sessions-test";
7
+
8
+ afterEach(() => {
9
+ if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
10
+ });
11
+
12
+ describe("loadSessions", () => {
13
+ it("returns empty object when dir does not exist", () => {
14
+ expect(loadSessions(tmpDir)).toEqual({});
15
+ });
16
+
17
+ it("returns empty object when file does not exist", () => {
18
+ mkdirSync(tmpDir, { recursive: true });
19
+ expect(loadSessions(tmpDir)).toEqual({});
20
+ });
21
+
22
+ it("reads sessions from file", () => {
23
+ mkdirSync(tmpDir, { recursive: true });
24
+ writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "abc-123" }));
25
+ expect(loadSessions(tmpDir)).toEqual({ mainSessionId: "abc-123" });
26
+ });
27
+
28
+ it("returns empty object when file is corrupt", () => {
29
+ mkdirSync(tmpDir, { recursive: true });
30
+ writeFileSync(join(tmpDir, "sessions.json"), "not json");
31
+ expect(loadSessions(tmpDir)).toEqual({});
32
+ });
33
+
34
+ it("strips unknown fields via schema", () => {
35
+ mkdirSync(tmpDir, { recursive: true });
36
+ writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "abc", extra: true }));
37
+ const result = loadSessions(tmpDir);
38
+ expect(result).toEqual({ mainSessionId: "abc" });
39
+ });
40
+ });
41
+
42
+ describe("saveSessions", () => {
43
+ it("creates directory and writes file", () => {
44
+ saveSessions({ mainSessionId: "new-id" }, tmpDir);
45
+ const saved = loadSessions(tmpDir);
46
+ expect(saved).toEqual({ mainSessionId: "new-id" });
47
+ });
48
+
49
+ it("overwrites existing file", () => {
50
+ saveSessions({ mainSessionId: "first" }, tmpDir);
51
+ saveSessions({ mainSessionId: "second" }, tmpDir);
52
+ expect(loadSessions(tmpDir)).toEqual({ mainSessionId: "second" });
53
+ });
54
+ });
55
+
56
+ describe("newSessionId", () => {
57
+ it("returns a valid UUID", () => {
58
+ const id = newSessionId();
59
+ expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
60
+ expect(newSessionId()).not.toBe(id);
61
+ });
62
+ });
@@ -0,0 +1,36 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { z } from "zod/v4";
5
+ import { createLogger } from "./logger";
6
+
7
+ const log = createLogger("sessions");
8
+
9
+ const sessionsSchema = z.object({
10
+ mainSessionId: z.string().optional(),
11
+ });
12
+
13
+ export type Sessions = z.infer<typeof sessionsSchema>;
14
+
15
+ const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
16
+
17
+ export function loadSessions(dir: string = defaultDir): Sessions {
18
+ try {
19
+ const path = join(dir, "sessions.json");
20
+ if (!existsSync(path)) return {};
21
+ const raw = readFileSync(path, "utf-8");
22
+ return sessionsSchema.parse(JSON.parse(raw));
23
+ } catch (err) {
24
+ log.warn({ err }, "Failed to load sessions.json, resetting to empty");
25
+ return {};
26
+ }
27
+ }
28
+
29
+ export function saveSessions(sessions: Sessions, dir: string = defaultDir): void {
30
+ mkdirSync(dir, { recursive: true });
31
+ writeFileSync(join(dir, "sessions.json"), `${JSON.stringify(sessions, null, 2)}\n`);
32
+ }
33
+
34
+ export function newSessionId(): string {
35
+ return randomUUID();
36
+ }
@@ -1,55 +1,220 @@
1
- import { afterEach, describe, expect, it } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
2
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { loadSettings, newSessionId, saveSettings } from "./settings";
4
+ import { applyEnvOverrides, loadSettings, printSettings, type Settings, saveSettings } from "./settings";
5
5
 
6
6
  const tmpDir = "/tmp/macroclaw-settings-test";
7
7
 
8
+ const validSettings: Settings = {
9
+ botToken: "123:ABC",
10
+ chatId: "12345678",
11
+ model: "sonnet",
12
+ workspace: "~/.macroclaw-workspace",
13
+ logLevel: "debug",
14
+ };
15
+
8
16
  afterEach(() => {
9
17
  if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
10
18
  });
11
19
 
12
20
  describe("loadSettings", () => {
13
- it("returns empty object when dir does not exist", () => {
14
- expect(loadSettings(tmpDir)).toEqual({});
21
+ it("returns null when file does not exist", () => {
22
+ expect(loadSettings(tmpDir)).toBeNull();
15
23
  });
16
24
 
17
- it("returns empty object when file does not exist", () => {
25
+ it("reads and validates settings from file", () => {
18
26
  mkdirSync(tmpDir, { recursive: true });
19
- expect(loadSettings(tmpDir)).toEqual({});
27
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
28
+ botToken: "123:ABC",
29
+ chatId: "12345678",
30
+ }));
31
+ const settings = loadSettings(tmpDir);
32
+ expect(settings).toEqual({
33
+ botToken: "123:ABC",
34
+ chatId: "12345678",
35
+ model: "sonnet",
36
+ workspace: "~/.macroclaw-workspace",
37
+ logLevel: "info",
38
+ });
20
39
  });
21
40
 
22
- it("reads settings from file", () => {
41
+ it("applies defaults for optional fields", () => {
23
42
  mkdirSync(tmpDir, { recursive: true });
24
- writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ sessionId: "abc-123" }));
25
- expect(loadSettings(tmpDir)).toEqual({ sessionId: "abc-123" });
43
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
44
+ botToken: "tok",
45
+ chatId: "123",
46
+ model: "opus",
47
+ workspace: "/custom",
48
+ openaiApiKey: "sk-test",
49
+ logLevel: "info",
50
+ pinoramaUrl: "http://localhost:6200",
51
+ }));
52
+ const settings = loadSettings(tmpDir);
53
+ expect(settings).toEqual({
54
+ botToken: "tok",
55
+ chatId: "123",
56
+ model: "opus",
57
+ workspace: "/custom",
58
+ openaiApiKey: "sk-test",
59
+ logLevel: "info",
60
+ pinoramaUrl: "http://localhost:6200",
61
+ });
26
62
  });
27
63
 
28
- it("returns empty object when file is corrupt", () => {
64
+ it("exits with code 1 when file is not valid JSON", () => {
29
65
  mkdirSync(tmpDir, { recursive: true });
30
66
  writeFileSync(join(tmpDir, "settings.json"), "not json");
31
- expect(loadSettings(tmpDir)).toEqual({});
67
+
68
+ const mockExit = mock(() => { throw new Error("exit"); });
69
+ const origExit = process.exit;
70
+ process.exit = mockExit as any;
71
+
72
+ try {
73
+ loadSettings(tmpDir);
74
+ } catch {
75
+ // expected
76
+ }
77
+
78
+ expect(mockExit).toHaveBeenCalledWith(1);
79
+ process.exit = origExit;
80
+ });
81
+
82
+ it("exits with code 1 when validation fails (missing required field)", () => {
83
+ mkdirSync(tmpDir, { recursive: true });
84
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ chatId: "123" }));
85
+
86
+ const mockExit = mock(() => { throw new Error("exit"); });
87
+ const origExit = process.exit;
88
+ process.exit = mockExit as any;
89
+
90
+ try {
91
+ loadSettings(tmpDir);
92
+ } catch {
93
+ // expected
94
+ }
95
+
96
+ expect(mockExit).toHaveBeenCalledWith(1);
97
+ process.exit = origExit;
98
+ });
99
+
100
+ it("exits with code 1 when validation fails (invalid logLevel)", () => {
101
+ mkdirSync(tmpDir, { recursive: true });
102
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
103
+ botToken: "tok",
104
+ chatId: "123",
105
+ logLevel: "verbose",
106
+ }));
107
+
108
+ const mockExit = mock(() => { throw new Error("exit"); });
109
+ const origExit = process.exit;
110
+ process.exit = mockExit as any;
111
+
112
+ try {
113
+ loadSettings(tmpDir);
114
+ } catch {
115
+ // expected
116
+ }
117
+
118
+ expect(mockExit).toHaveBeenCalledWith(1);
119
+ process.exit = origExit;
32
120
  });
33
121
  });
34
122
 
35
123
  describe("saveSettings", () => {
36
124
  it("creates directory and writes file", () => {
37
- saveSettings({ sessionId: "new-id" }, tmpDir);
125
+ saveSettings(validSettings, tmpDir);
38
126
  const saved = loadSettings(tmpDir);
39
- expect(saved).toEqual({ sessionId: "new-id" });
127
+ expect(saved).toEqual(validSettings);
40
128
  });
41
129
 
42
130
  it("overwrites existing file", () => {
43
- saveSettings({ sessionId: "first" }, tmpDir);
44
- saveSettings({ sessionId: "second" }, tmpDir);
45
- expect(loadSettings(tmpDir)).toEqual({ sessionId: "second" });
131
+ saveSettings(validSettings, tmpDir);
132
+ const updated = { ...validSettings, model: "opus" };
133
+ saveSettings(updated, tmpDir);
134
+ expect(loadSettings(tmpDir)).toEqual(updated);
135
+ });
136
+ });
137
+
138
+ describe("applyEnvOverrides", () => {
139
+ const envVars = [
140
+ "TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
141
+ "WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
142
+ ];
143
+ const savedEnv: Record<string, string | undefined> = {};
144
+
145
+ beforeEach(() => {
146
+ for (const v of envVars) {
147
+ savedEnv[v] = process.env[v];
148
+ delete process.env[v];
149
+ }
150
+ });
151
+
152
+ afterEach(() => {
153
+ for (const v of envVars) {
154
+ if (savedEnv[v] !== undefined) process.env[v] = savedEnv[v];
155
+ else delete process.env[v];
156
+ }
157
+ });
158
+
159
+ it("returns original settings when no env vars set", () => {
160
+ const { settings, overrides } = applyEnvOverrides(validSettings);
161
+ expect(settings).toEqual(validSettings);
162
+ expect(overrides.size).toBe(0);
163
+ });
164
+
165
+ it("overrides model from MODEL env var", () => {
166
+ process.env.MODEL = "opus";
167
+ const { settings, overrides } = applyEnvOverrides(validSettings);
168
+ expect(settings.model).toBe("opus");
169
+ expect(overrides.has("model")).toBe(true);
170
+ });
171
+
172
+ it("overrides multiple fields and tracks them", () => {
173
+ process.env.TELEGRAM_BOT_TOKEN = "override-token";
174
+ process.env.AUTHORIZED_CHAT_ID = "99999";
175
+ process.env.OPENAI_API_KEY = "sk-override";
176
+ const { settings, overrides } = applyEnvOverrides(validSettings);
177
+ expect(settings.botToken).toBe("override-token");
178
+ expect(settings.chatId).toBe("99999");
179
+ expect(settings.openaiApiKey).toBe("sk-override");
180
+ expect(overrides.size).toBe(3);
181
+ });
182
+
183
+ it("overrides workspace and log-related fields", () => {
184
+ process.env.WORKSPACE = "/override/path";
185
+ process.env.LOG_LEVEL = "error";
186
+ process.env.PINORAMA_URL = "http://override:6200";
187
+ const { settings, overrides } = applyEnvOverrides(validSettings);
188
+ expect(settings.workspace).toBe("/override/path");
189
+ expect(settings.logLevel).toBe("error");
190
+ expect(settings.pinoramaUrl).toBe("http://override:6200");
191
+ expect(overrides.has("workspace")).toBe(true);
192
+ expect(overrides.has("logLevel")).toBe(true);
193
+ expect(overrides.has("pinoramaUrl")).toBe(true);
46
194
  });
47
195
  });
48
196
 
49
- describe("newSessionId", () => {
50
- it("returns a valid UUID", () => {
51
- const id = newSessionId();
52
- expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
53
- expect(newSessionId()).not.toBe(id);
197
+ describe("printSettings", () => {
198
+ it("does not throw", () => {
199
+ expect(() => printSettings(validSettings, new Set())).not.toThrow();
200
+ });
201
+
202
+ it("does not throw with overrides", () => {
203
+ expect(() => printSettings(validSettings, new Set(["model"]))).not.toThrow();
204
+ });
205
+
206
+ it("does not throw with optional fields set", () => {
207
+ const full: Settings = {
208
+ ...validSettings,
209
+ openaiApiKey: "sk-1234567890",
210
+ pinoramaUrl: "http://localhost:6200",
211
+ };
212
+ expect(() => printSettings(full, new Set(["model", "openaiApiKey"]))).not.toThrow();
213
+ });
214
+
215
+ it("masks botToken showing only last 4 chars", () => {
216
+ // We test the masking indirectly — printSettings shouldn't throw
217
+ // and the function exists primarily for the startup log
218
+ expect(() => printSettings({ ...validSettings, botToken: "ab" }, new Set())).not.toThrow();
54
219
  });
55
220
  });
package/src/settings.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
3
  import { z } from "zod/v4";
@@ -6,8 +5,14 @@ import { createLogger } from "./logger";
6
5
 
7
6
  const log = createLogger("settings");
8
7
 
9
- const settingsSchema = z.object({
10
- sessionId: z.string().optional(),
8
+ export const settingsSchema = z.object({
9
+ botToken: z.string(),
10
+ chatId: z.string(),
11
+ model: z.string().default("sonnet"),
12
+ workspace: z.string().default("~/.macroclaw-workspace"),
13
+ openaiApiKey: z.string().optional(),
14
+ logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
15
+ pinoramaUrl: z.string().optional(),
11
16
  });
12
17
 
13
18
  export type Settings = z.infer<typeof settingsSchema>;
@@ -15,15 +20,24 @@ export type Settings = z.infer<typeof settingsSchema>;
15
20
  const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
16
21
 
17
22
  export function loadSettings(dir: string = defaultDir): Settings {
23
+ const path = join(dir, "settings.json");
24
+ if (!existsSync(path)) return null as unknown as Settings;
25
+
26
+ let raw: unknown;
18
27
  try {
19
- const path = join(dir, "settings.json");
20
- if (!existsSync(path)) return {};
21
- const raw = readFileSync(path, "utf-8");
22
- return settingsSchema.parse(JSON.parse(raw));
28
+ raw = JSON.parse(readFileSync(path, "utf-8"));
23
29
  } catch (err) {
24
- log.warn({ err }, "Failed to load settings.json");
25
- return {};
30
+ log.error({ err }, "settings.json is not valid JSON");
31
+ process.exit(1);
32
+ }
33
+
34
+ const result = settingsSchema.safeParse(raw);
35
+ if (!result.success) {
36
+ log.error({ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, "settings.json validation failed");
37
+ process.exit(1);
26
38
  }
39
+
40
+ return result.data;
27
41
  }
28
42
 
29
43
  export function saveSettings(settings: Settings, dir: string = defaultDir): void {
@@ -31,6 +45,50 @@ export function saveSettings(settings: Settings, dir: string = defaultDir): void
31
45
  writeFileSync(join(dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
32
46
  }
33
47
 
34
- export function newSessionId(): string {
35
- return randomUUID();
48
+ // --- Env var overrides ---
49
+
50
+ const envMapping: Record<keyof Settings, string> = {
51
+ botToken: "TELEGRAM_BOT_TOKEN",
52
+ chatId: "AUTHORIZED_CHAT_ID",
53
+ model: "MODEL",
54
+ workspace: "WORKSPACE",
55
+ openaiApiKey: "OPENAI_API_KEY",
56
+ logLevel: "LOG_LEVEL",
57
+ pinoramaUrl: "PINORAMA_URL",
58
+ };
59
+
60
+ export function applyEnvOverrides(settings: Settings): { settings: Settings; overrides: Set<string> } {
61
+ const merged = { ...settings };
62
+ const overrides = new Set<string>();
63
+
64
+ for (const [key, envVar] of Object.entries(envMapping)) {
65
+ const value = process.env[envVar];
66
+ if (value !== undefined) {
67
+ (merged as Record<string, unknown>)[key] = value;
68
+ overrides.add(key);
69
+ }
70
+ }
71
+
72
+ return { settings: merged, overrides };
73
+ }
74
+
75
+ // --- Startup log ---
76
+
77
+ export function maskValue(key: string, value: string | undefined): string {
78
+ if (value === undefined) return "(not set)";
79
+ if (key === "botToken" || key === "openaiApiKey") {
80
+ return value.length > 4 ? `****${value.slice(-4)}` : "****";
81
+ }
82
+ return value;
83
+ }
84
+
85
+ export function printSettings(settings: Settings, overrides: Set<string>): void {
86
+ const lines = ["Settings:"];
87
+ for (const key of Object.keys(envMapping) as (keyof Settings)[]) {
88
+ const value = settings[key];
89
+ const masked = maskValue(key, value);
90
+ const suffix = overrides.has(key) ? " (env)" : "";
91
+ lines.push(` ${key}: ${masked}${suffix}`);
92
+ }
93
+ log.info(lines.join("\n"));
36
94
  }
@@ -0,0 +1,241 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test";
2
+ import type { SetupIO } from "./setup";
3
+
4
+ // Mock Grammy Bot
5
+ const mockBotInit = mock(async () => {});
6
+ const mockBotStart = mock(() => {});
7
+ const mockBotStop = mock(async () => {});
8
+ const mockSetMyCommands = mock(async () => {});
9
+ let mockBotCatchHandler: Function | null = null;
10
+ let mockBotCommandHandler: Function | null = null;
11
+
12
+ mock.module("grammy", () => ({
13
+ Bot: class MockBot {
14
+ token: string;
15
+ botInfo = { username: "test_bot" };
16
+
17
+ api = { setMyCommands: mockSetMyCommands };
18
+
19
+ constructor(token: string) {
20
+ this.token = token;
21
+ }
22
+
23
+ command(_name: string, handler: Function) {
24
+ mockBotCommandHandler = handler;
25
+ }
26
+
27
+ catch(handler: Function) {
28
+ mockBotCatchHandler = handler;
29
+ }
30
+
31
+ async init() {
32
+ await mockBotInit();
33
+ }
34
+
35
+ start() {
36
+ mockBotStart();
37
+ }
38
+
39
+ async stop() {
40
+ await mockBotStop();
41
+ }
42
+ },
43
+ }));
44
+
45
+ const { runSetupWizard } = await import("./setup");
46
+
47
+ function createMockIO(inputs: string[]): SetupIO {
48
+ let index = 0;
49
+ const written: string[] = [];
50
+ return {
51
+ ask: async () => inputs[index++] ?? "",
52
+ write: (msg: string) => { written.push(msg); },
53
+ };
54
+ }
55
+
56
+ // Save/restore env vars
57
+ const envVars = ["TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL", "WORKSPACE", "OPENAI_API_KEY"];
58
+ const savedEnv: Record<string, string | undefined> = {};
59
+
60
+ beforeEach(() => {
61
+ for (const v of envVars) {
62
+ savedEnv[v] = process.env[v];
63
+ delete process.env[v];
64
+ }
65
+ mockBotInit.mockImplementation(async () => {});
66
+ mockBotStart.mockClear();
67
+ mockBotStop.mockClear();
68
+ mockSetMyCommands.mockClear();
69
+ mockBotCatchHandler = null;
70
+ mockBotCommandHandler = null;
71
+ });
72
+
73
+ afterEach(() => {
74
+ for (const v of envVars) {
75
+ if (savedEnv[v] !== undefined) process.env[v] = savedEnv[v];
76
+ else delete process.env[v];
77
+ }
78
+ });
79
+
80
+ describe("runSetupWizard", () => {
81
+ it("collects all required fields via prompts", async () => {
82
+ const io = createMockIO([
83
+ "123:ABC", // bot token
84
+ "12345678", // chat ID
85
+ "opus", // model
86
+ "/my/ws", // workspace
87
+ "sk-test", // openai key
88
+ ]);
89
+
90
+ const settings = await runSetupWizard(io);
91
+
92
+ expect(settings.botToken).toBe("123:ABC");
93
+ expect(settings.chatId).toBe("12345678");
94
+ expect(settings.model).toBe("opus");
95
+ expect(settings.workspace).toBe("/my/ws");
96
+ expect(settings.openaiApiKey).toBe("sk-test");
97
+ expect(settings.logLevel).toBe("debug");
98
+ });
99
+
100
+ it("uses defaults from env vars when user presses enter", async () => {
101
+ process.env.TELEGRAM_BOT_TOKEN = "env-token";
102
+ process.env.AUTHORIZED_CHAT_ID = "env-chat";
103
+ process.env.MODEL = "haiku";
104
+ process.env.WORKSPACE = "/env/ws";
105
+ process.env.OPENAI_API_KEY = "sk-env";
106
+
107
+ const io = createMockIO([
108
+ "", // accept default token
109
+ "", // accept default chat ID
110
+ "", // accept default model
111
+ "", // accept default workspace
112
+ "", // accept default openai key
113
+ ]);
114
+
115
+ const settings = await runSetupWizard(io);
116
+
117
+ expect(settings.botToken).toBe("env-token");
118
+ expect(settings.chatId).toBe("env-chat");
119
+ expect(settings.model).toBe("haiku");
120
+ expect(settings.workspace).toBe("/env/ws");
121
+ expect(settings.openaiApiKey).toBe("sk-env");
122
+ });
123
+
124
+ it("uses sonnet as default model when no env var", async () => {
125
+ const io = createMockIO([
126
+ "tok",
127
+ "123",
128
+ "", // press enter for default model
129
+ "", // press enter for default workspace
130
+ "", // press enter for no openai key
131
+ ]);
132
+
133
+ const settings = await runSetupWizard(io);
134
+
135
+ expect(settings.model).toBe("sonnet");
136
+ expect(settings.workspace).toBe("~/.macroclaw-workspace");
137
+ expect(settings.openaiApiKey).toBeUndefined();
138
+ });
139
+
140
+ it("starts and stops the setup bot", async () => {
141
+ const io = createMockIO([
142
+ "tok",
143
+ "123",
144
+ "",
145
+ "",
146
+ "",
147
+ ]);
148
+
149
+ await runSetupWizard(io);
150
+
151
+ expect(mockBotInit).toHaveBeenCalled();
152
+ expect(mockBotStart).toHaveBeenCalled();
153
+ expect(mockBotStop).toHaveBeenCalled();
154
+ });
155
+
156
+ it("re-prompts on invalid bot token", async () => {
157
+ let callCount = 0;
158
+ mockBotInit.mockImplementation(async () => {
159
+ callCount++;
160
+ if (callCount === 1) throw new Error("Invalid token");
161
+ });
162
+
163
+ const io = createMockIO([
164
+ "bad-token", // first attempt — fails
165
+ "good-token", // second attempt — succeeds
166
+ "123",
167
+ "",
168
+ "",
169
+ "",
170
+ ]);
171
+
172
+ const settings = await runSetupWizard(io);
173
+
174
+ expect(settings.botToken).toBe("good-token");
175
+ expect(callCount).toBe(2);
176
+ });
177
+
178
+ it("re-prompts when bot token is empty", async () => {
179
+ const io = createMockIO([
180
+ "", // empty — re-prompt
181
+ "actual-token",
182
+ "123",
183
+ "",
184
+ "",
185
+ "",
186
+ ]);
187
+
188
+ const settings = await runSetupWizard(io);
189
+
190
+ expect(settings.botToken).toBe("actual-token");
191
+ });
192
+
193
+ it("re-prompts when chat ID is empty", async () => {
194
+ const io = createMockIO([
195
+ "tok",
196
+ "", // empty — re-prompt
197
+ "456",
198
+ "",
199
+ "",
200
+ "",
201
+ ]);
202
+
203
+ const settings = await runSetupWizard(io);
204
+
205
+ expect(settings.chatId).toBe("456");
206
+ });
207
+
208
+ it("registers and uses catch handler on setup bot", async () => {
209
+ const io = createMockIO([
210
+ "tok",
211
+ "123",
212
+ "",
213
+ "",
214
+ "",
215
+ ]);
216
+
217
+ await runSetupWizard(io);
218
+
219
+ // The catch handler was registered
220
+ expect(mockBotCatchHandler).not.toBeNull();
221
+ // Calling it should not throw (it logs internally)
222
+ expect(() => mockBotCatchHandler!(new Error("test error"))).not.toThrow();
223
+ });
224
+
225
+ it("registers /chatid command handler on setup bot", async () => {
226
+ const io = createMockIO([
227
+ "tok",
228
+ "123",
229
+ "",
230
+ "",
231
+ "",
232
+ ]);
233
+
234
+ await runSetupWizard(io);
235
+
236
+ expect(mockBotCommandHandler).not.toBeNull();
237
+ const mockReply = mock(() => {});
238
+ mockBotCommandHandler!({ chat: { id: 12345 }, reply: mockReply });
239
+ expect(mockReply).toHaveBeenCalledWith("12345");
240
+ });
241
+ });
package/src/setup.ts ADDED
@@ -0,0 +1,94 @@
1
+ import { Bot } from "grammy";
2
+ import { createLogger } from "./logger";
3
+ import { maskValue, type Settings, settingsSchema } from "./settings";
4
+
5
+ const log = createLogger("setup");
6
+
7
+ export interface SetupIO {
8
+ ask: (question: string) => Promise<string>;
9
+ write: (msg: string) => void;
10
+ }
11
+
12
+ async function startSetupBot(token: string): Promise<Bot> {
13
+ const bot = new Bot(token);
14
+ bot.command("chatid", (ctx) => {
15
+ ctx.reply(ctx.chat.id.toString());
16
+ });
17
+ bot.catch((err) => {
18
+ log.debug({ err }, "Setup bot error");
19
+ });
20
+
21
+ await bot.init();
22
+ await bot.api.setMyCommands([{ command: "chatid", description: "Get your chat ID" }]);
23
+ log.info({ username: bot.botInfo.username }, "Setup bot started");
24
+
25
+ bot.start();
26
+ return bot;
27
+ }
28
+
29
+ export async function runSetupWizard(io: SetupIO): Promise<Settings> {
30
+ const { ask, write } = io;
31
+
32
+ write("\n=== Macroclaw Setup ===\n\n");
33
+
34
+ // Bot token
35
+ const defaultToken = process.env.TELEGRAM_BOT_TOKEN || "";
36
+ const tokenPrompt = defaultToken ? `Bot token [${maskValue("botToken", defaultToken)}]: ` : "Bot token: ";
37
+ let botToken = await ask(tokenPrompt) || defaultToken;
38
+
39
+ // Validate token by starting a temporary bot
40
+ let setupBot: Bot | null = null;
41
+ while (true) {
42
+ if (!botToken) {
43
+ botToken = await ask("Bot token (required): ");
44
+ continue;
45
+ }
46
+ try {
47
+ setupBot = await startSetupBot(botToken);
48
+ write(`Bot @${setupBot.botInfo.username} connected. Send /chatid to the bot to get your chat ID.\n`);
49
+ break;
50
+ } catch {
51
+ write("Invalid bot token. Please try again.\n");
52
+ botToken = await ask("Bot token: ");
53
+ }
54
+ }
55
+
56
+ // Chat ID
57
+ const defaultChatId = process.env.AUTHORIZED_CHAT_ID || "";
58
+ const chatIdPrompt = defaultChatId ? `Chat ID [${defaultChatId}]: ` : "Chat ID: ";
59
+ let chatId = await ask(chatIdPrompt) || defaultChatId;
60
+ while (!chatId) {
61
+ chatId = await ask("Chat ID (required): ");
62
+ }
63
+
64
+ // Stop setup bot
65
+ if (setupBot) {
66
+ await setupBot.stop();
67
+ }
68
+
69
+ // Model
70
+ const defaultModel = process.env.MODEL || "sonnet";
71
+ const model = await ask(`Model [${defaultModel}]: `) || defaultModel;
72
+
73
+ // Workspace
74
+ const defaultWorkspace = process.env.WORKSPACE || "~/.macroclaw-workspace";
75
+ const workspace = await ask(`Workspace [${defaultWorkspace}]: `) || defaultWorkspace;
76
+
77
+ // OpenAI API key
78
+ const defaultOpenai = process.env.OPENAI_API_KEY || "";
79
+ const openaiPrompt = defaultOpenai ? `OpenAI API key [${maskValue("openaiApiKey", defaultOpenai)}] (optional): ` : "OpenAI API key (optional): ";
80
+ const openaiApiKey = await ask(openaiPrompt) || defaultOpenai || undefined;
81
+
82
+ const settings: Settings = settingsSchema.parse({
83
+ botToken,
84
+ chatId,
85
+ model,
86
+ workspace,
87
+ openaiApiKey,
88
+ logLevel: "debug",
89
+ });
90
+
91
+ write("\nSetup complete!\n\n");
92
+
93
+ return settings;
94
+ }