macroclaw 0.12.0 → 0.14.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.
@@ -1,7 +1,7 @@
1
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 { applyEnvOverrides, loadSettings, printSettings, type Settings, saveSettings } from "./settings";
4
+ import { type Settings, SettingsManager } from "./settings";
5
5
 
6
6
  const tmpDir = "/tmp/macroclaw-settings-test";
7
7
 
@@ -17,9 +17,14 @@ afterEach(() => {
17
17
  if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true });
18
18
  });
19
19
 
20
- describe("loadSettings", () => {
21
- it("returns null when file does not exist", () => {
22
- expect(loadSettings(tmpDir)).toBeNull();
20
+ describe("SettingsManager.load", () => {
21
+ it("exits when file does not exist", () => {
22
+ const mockExit = mock((_code?: number) => { throw new Error("exit"); });
23
+ const origExit = process.exit;
24
+ process.exit = mockExit as typeof process.exit;
25
+ expect(() => new SettingsManager(tmpDir).load()).toThrow("exit");
26
+ process.exit = origExit;
27
+ expect(mockExit).toHaveBeenCalledWith(1);
23
28
  });
24
29
 
25
30
  it("reads and validates settings from file", () => {
@@ -28,7 +33,7 @@ describe("loadSettings", () => {
28
33
  botToken: "123:ABC",
29
34
  chatId: "12345678",
30
35
  }));
31
- const settings = loadSettings(tmpDir);
36
+ const settings = new SettingsManager(tmpDir).load();
32
37
  expect(settings).toEqual({
33
38
  botToken: "123:ABC",
34
39
  chatId: "12345678",
@@ -49,7 +54,7 @@ describe("loadSettings", () => {
49
54
  logLevel: "info",
50
55
  pinoramaUrl: "http://localhost:6200",
51
56
  }));
52
- const settings = loadSettings(tmpDir);
57
+ const settings = new SettingsManager(tmpDir).load();
53
58
  expect(settings).toEqual({
54
59
  botToken: "tok",
55
60
  chatId: "123",
@@ -70,7 +75,7 @@ describe("loadSettings", () => {
70
75
  process.exit = mockExit as any;
71
76
 
72
77
  try {
73
- loadSettings(tmpDir);
78
+ new SettingsManager(tmpDir).load();
74
79
  } catch {
75
80
  // expected
76
81
  }
@@ -88,7 +93,7 @@ describe("loadSettings", () => {
88
93
  process.exit = mockExit as any;
89
94
 
90
95
  try {
91
- loadSettings(tmpDir);
96
+ new SettingsManager(tmpDir).load();
92
97
  } catch {
93
98
  // expected
94
99
  }
@@ -110,7 +115,7 @@ describe("loadSettings", () => {
110
115
  process.exit = mockExit as any;
111
116
 
112
117
  try {
113
- loadSettings(tmpDir);
118
+ new SettingsManager(tmpDir).load();
114
119
  } catch {
115
120
  // expected
116
121
  }
@@ -120,22 +125,48 @@ describe("loadSettings", () => {
120
125
  });
121
126
  });
122
127
 
123
- describe("saveSettings", () => {
128
+ describe("SettingsManager.save", () => {
124
129
  it("creates directory and writes file", () => {
125
- saveSettings(validSettings, tmpDir);
126
- const saved = loadSettings(tmpDir);
130
+ const mgr = new SettingsManager(tmpDir);
131
+ mgr.save(validSettings);
132
+ const saved = mgr.load();
127
133
  expect(saved).toEqual(validSettings);
128
134
  });
129
135
 
130
136
  it("overwrites existing file", () => {
131
- saveSettings(validSettings, tmpDir);
132
- const updated = { ...validSettings, model: "opus" };
133
- saveSettings(updated, tmpDir);
134
- expect(loadSettings(tmpDir)).toEqual(updated);
137
+ const mgr = new SettingsManager(tmpDir);
138
+ mgr.save(validSettings);
139
+ const updated = { ...validSettings, model: "opus" as const };
140
+ mgr.save(updated);
141
+ expect(mgr.load()).toEqual(updated);
135
142
  });
136
143
  });
137
144
 
138
- describe("applyEnvOverrides", () => {
145
+ describe("SettingsManager.loadRaw", () => {
146
+ it("returns null when file does not exist", () => {
147
+ expect(new SettingsManager("/nonexistent/path").loadRaw()).toBeNull();
148
+ });
149
+
150
+ it("reads and parses valid settings file", () => {
151
+ mkdirSync(tmpDir, { recursive: true });
152
+ writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", chatId: "123" }));
153
+ expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", chatId: "123" });
154
+ });
155
+
156
+ it("returns null for invalid JSON", () => {
157
+ mkdirSync(tmpDir, { recursive: true });
158
+ writeFileSync(join(tmpDir, "settings.json"), "not json");
159
+ expect(new SettingsManager(tmpDir).loadRaw()).toBeNull();
160
+ });
161
+
162
+ it("returns null for non-object JSON", () => {
163
+ mkdirSync(tmpDir, { recursive: true });
164
+ writeFileSync(join(tmpDir, "settings.json"), '"just a string"');
165
+ expect(new SettingsManager(tmpDir).loadRaw()).toBeNull();
166
+ });
167
+ });
168
+
169
+ describe("SettingsManager.applyEnvOverrides", () => {
139
170
  const envVars = [
140
171
  "TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
141
172
  "WORKSPACE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
@@ -157,14 +188,14 @@ describe("applyEnvOverrides", () => {
157
188
  });
158
189
 
159
190
  it("returns original settings when no env vars set", () => {
160
- const { settings, overrides } = applyEnvOverrides(validSettings);
191
+ const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
161
192
  expect(settings).toEqual(validSettings);
162
193
  expect(overrides.size).toBe(0);
163
194
  });
164
195
 
165
196
  it("overrides model from MODEL env var", () => {
166
197
  process.env.MODEL = "opus";
167
- const { settings, overrides } = applyEnvOverrides(validSettings);
198
+ const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
168
199
  expect(settings.model).toBe("opus");
169
200
  expect(overrides.has("model")).toBe(true);
170
201
  });
@@ -173,7 +204,7 @@ describe("applyEnvOverrides", () => {
173
204
  process.env.TELEGRAM_BOT_TOKEN = "override-token";
174
205
  process.env.AUTHORIZED_CHAT_ID = "99999";
175
206
  process.env.OPENAI_API_KEY = "sk-override";
176
- const { settings, overrides } = applyEnvOverrides(validSettings);
207
+ const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
177
208
  expect(settings.botToken).toBe("override-token");
178
209
  expect(settings.chatId).toBe("99999");
179
210
  expect(settings.openaiApiKey).toBe("sk-override");
@@ -184,7 +215,7 @@ describe("applyEnvOverrides", () => {
184
215
  process.env.WORKSPACE = "/override/path";
185
216
  process.env.LOG_LEVEL = "error";
186
217
  process.env.PINORAMA_URL = "http://override:6200";
187
- const { settings, overrides } = applyEnvOverrides(validSettings);
218
+ const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
188
219
  expect(settings.workspace).toBe("/override/path");
189
220
  expect(settings.logLevel).toBe("error");
190
221
  expect(settings.pinoramaUrl).toBe("http://override:6200");
@@ -194,13 +225,13 @@ describe("applyEnvOverrides", () => {
194
225
  });
195
226
  });
196
227
 
197
- describe("printSettings", () => {
228
+ describe("SettingsManager.print", () => {
198
229
  it("does not throw", () => {
199
- expect(() => printSettings(validSettings, new Set())).not.toThrow();
230
+ expect(() => new SettingsManager(tmpDir).print(validSettings, new Set())).not.toThrow();
200
231
  });
201
232
 
202
233
  it("does not throw with overrides", () => {
203
- expect(() => printSettings(validSettings, new Set(["model"]))).not.toThrow();
234
+ expect(() => new SettingsManager(tmpDir).print(validSettings, new Set(["model"]))).not.toThrow();
204
235
  });
205
236
 
206
237
  it("does not throw with optional fields set", () => {
@@ -209,12 +240,18 @@ describe("printSettings", () => {
209
240
  openaiApiKey: "sk-1234567890",
210
241
  pinoramaUrl: "http://localhost:6200",
211
242
  };
212
- expect(() => printSettings(full, new Set(["model", "openaiApiKey"]))).not.toThrow();
243
+ expect(() => new SettingsManager(tmpDir).print(full, new Set(["model", "openaiApiKey"]))).not.toThrow();
213
244
  });
214
245
 
215
246
  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();
247
+ expect(() => new SettingsManager(tmpDir).print({ ...validSettings, botToken: "ab" }, new Set())).not.toThrow();
248
+ });
249
+ });
250
+
251
+ describe("SettingsManager.envMapping", () => {
252
+ it("is a static property with all settings keys", () => {
253
+ expect(SettingsManager.envMapping.botToken).toBe("TELEGRAM_BOT_TOKEN");
254
+ expect(SettingsManager.envMapping.chatId).toBe("AUTHORIZED_CHAT_ID");
255
+ expect(SettingsManager.envMapping.model).toBe("MODEL");
219
256
  });
220
257
  });
package/src/settings.ts CHANGED
@@ -7,8 +7,8 @@ const log = createLogger("settings");
7
7
 
8
8
  export const settingsSchema = z.object({
9
9
  botToken: z.string(),
10
- chatId: z.string(),
11
- model: z.string().default("sonnet"),
10
+ chatId: z.string().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
11
+ model: z.enum(["haiku", "sonnet", "opus"]).default("sonnet"),
12
12
  workspace: z.string().default("~/.macroclaw-workspace"),
13
13
  openaiApiKey: z.string().optional(),
14
14
  logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
@@ -17,78 +17,99 @@ export const settingsSchema = z.object({
17
17
 
18
18
  export type Settings = z.infer<typeof settingsSchema>;
19
19
 
20
- const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
20
+ export function maskValue(key: string, value: string | undefined): string {
21
+ if (value === undefined) return "(not set)";
22
+ if (key === "botToken" || key === "openaiApiKey") {
23
+ return value.length > 4 ? `****${value.slice(-4)}` : "****";
24
+ }
25
+ return value;
26
+ }
21
27
 
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;
28
+ const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
25
29
 
26
- let raw: unknown;
27
- try {
28
- raw = JSON.parse(readFileSync(path, "utf-8"));
29
- } catch (err) {
30
- log.error({ err }, "settings.json is not valid JSON");
31
- process.exit(1);
30
+ export class SettingsManager {
31
+ readonly #dir: string;
32
+
33
+ static readonly envMapping: Record<keyof Settings, string> = {
34
+ botToken: "TELEGRAM_BOT_TOKEN",
35
+ chatId: "AUTHORIZED_CHAT_ID",
36
+ model: "MODEL",
37
+ workspace: "WORKSPACE",
38
+ openaiApiKey: "OPENAI_API_KEY",
39
+ logLevel: "LOG_LEVEL",
40
+ pinoramaUrl: "PINORAMA_URL",
41
+ };
42
+
43
+ constructor(dir: string = defaultDir) {
44
+ this.#dir = dir;
32
45
  }
33
46
 
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);
47
+ get dir(): string {
48
+ return this.#dir;
38
49
  }
39
50
 
40
- return result.data;
41
- }
51
+ load(): Settings {
52
+ const path = join(this.#dir, "settings.json");
53
+ if (!existsSync(path)) {
54
+ log.error({ path }, "settings.json not found. Run `macroclaw setup` first.");
55
+ process.exit(1);
56
+ }
42
57
 
43
- export function saveSettings(settings: Settings, dir: string = defaultDir): void {
44
- mkdirSync(dir, { recursive: true });
45
- writeFileSync(join(dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
46
- }
58
+ let raw: unknown;
59
+ try {
60
+ raw = JSON.parse(readFileSync(path, "utf-8"));
61
+ } catch {
62
+ raw = null;
63
+ }
64
+
65
+ const result = settingsSchema.safeParse(raw);
66
+ if (!result.success) {
67
+ log.error({ issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`) }, "settings.json validation failed");
68
+ process.exit(1);
69
+ }
70
+
71
+ return result.data;
72
+ }
47
73
 
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);
74
+ loadRaw(): Record<string, unknown> | null {
75
+ const path = join(this.#dir, "settings.json");
76
+ if (!existsSync(path)) return null;
77
+ try {
78
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
79
+ return typeof raw === "object" && raw !== null ? raw : null;
80
+ } catch {
81
+ return null;
69
82
  }
70
83
  }
71
84
 
72
- return { settings: merged, overrides };
73
- }
85
+ save(settings: Settings): void {
86
+ mkdirSync(this.#dir, { recursive: true });
87
+ writeFileSync(join(this.#dir, "settings.json"), `${JSON.stringify(settings, null, 2)}\n`);
88
+ }
74
89
 
75
- // --- Startup log ---
90
+ applyEnvOverrides(settings: Settings): { settings: Settings; overrides: Set<string> } {
91
+ const merged = { ...settings };
92
+ const overrides = new Set<string>();
76
93
 
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)}` : "****";
94
+ for (const [key, envVar] of Object.entries(SettingsManager.envMapping)) {
95
+ const value = process.env[envVar];
96
+ if (value !== undefined) {
97
+ (merged as Record<string, unknown>)[key] = value;
98
+ overrides.add(key);
99
+ }
100
+ }
101
+
102
+ return { settings: merged, overrides };
81
103
  }
82
- return value;
83
- }
84
104
 
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}`);
105
+ print(settings: Settings, overrides: Set<string>): void {
106
+ const lines = ["Settings:"];
107
+ for (const key of Object.keys(SettingsManager.envMapping) as (keyof Settings)[]) {
108
+ const value = settings[key];
109
+ const masked = maskValue(key, value);
110
+ const suffix = overrides.has(key) ? " (env)" : "";
111
+ lines.push(` ${key}: ${masked}${suffix}`);
112
+ }
113
+ log.info(lines.join("\n"));
92
114
  }
93
- log.info(lines.join("\n"));
94
115
  }