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 +29 -6
- package/package.json +1 -1
- package/src/app.test.ts +2 -2
- package/src/index.ts +28 -12
- package/src/logger.ts +2 -2
- package/src/orchestrator.test.ts +10 -10
- package/src/orchestrator.ts +8 -8
- package/src/sessions.test.ts +62 -0
- package/src/sessions.ts +36 -0
- package/src/settings.test.ts +186 -21
- package/src/settings.ts +69 -11
- package/src/setup.test.ts +241 -0
- package/src/setup.ts +94 -0
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
|
-
##
|
|
66
|
+
## Setup
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
# Run directly (no install needed)
|
|
70
|
-
|
|
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
|
|
80
|
-
|
|
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
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 {
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
19
|
-
|
|
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:
|
|
37
|
-
authorizedChatId:
|
|
52
|
+
botToken: settings.botToken,
|
|
53
|
+
authorizedChatId: settings.chatId,
|
|
38
54
|
workspace,
|
|
39
|
-
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 || "
|
|
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 || "
|
|
14
|
+
{ level: process.env.LOG_LEVEL || "info" },
|
|
15
15
|
pino.multistream(streams),
|
|
16
16
|
);
|
|
17
17
|
|
package/src/orchestrator.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 { type Claude, type ClaudeDeferredResult, ClaudeParseError, ClaudeProcessError, type ClaudeResult, type ClaudeRunOptions } from "./claude";
|
|
4
4
|
import { Orchestrator, type OrchestratorConfig, type OrchestratorResponse } from "./orchestrator";
|
|
5
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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> =>
|
package/src/orchestrator.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
#
|
|
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.#
|
|
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.#
|
|
103
|
-
this.#sessionId = this.#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/sessions.ts
ADDED
|
@@ -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
|
+
}
|
package/src/settings.test.ts
CHANGED
|
@@ -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,
|
|
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
|
|
14
|
-
expect(loadSettings(tmpDir)).
|
|
21
|
+
it("returns null when file does not exist", () => {
|
|
22
|
+
expect(loadSettings(tmpDir)).toBeNull();
|
|
15
23
|
});
|
|
16
24
|
|
|
17
|
-
it("
|
|
25
|
+
it("reads and validates settings from file", () => {
|
|
18
26
|
mkdirSync(tmpDir, { recursive: true });
|
|
19
|
-
|
|
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("
|
|
41
|
+
it("applies defaults for optional fields", () => {
|
|
23
42
|
mkdirSync(tmpDir, { recursive: true });
|
|
24
|
-
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
25
|
-
|
|
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("
|
|
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
|
-
|
|
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(
|
|
125
|
+
saveSettings(validSettings, tmpDir);
|
|
38
126
|
const saved = loadSettings(tmpDir);
|
|
39
|
-
expect(saved).toEqual(
|
|
127
|
+
expect(saved).toEqual(validSettings);
|
|
40
128
|
});
|
|
41
129
|
|
|
42
130
|
it("overwrites existing file", () => {
|
|
43
|
-
saveSettings(
|
|
44
|
-
|
|
45
|
-
|
|
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("
|
|
50
|
-
it("
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
25
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|
+
}
|