macroclaw 0.45.0 → 0.47.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 +46 -13
- package/package.json +1 -1
- package/src/app.test.ts +388 -5
- package/src/app.ts +216 -45
- package/src/authorized-chats.test.ts +196 -0
- package/src/authorized-chats.ts +122 -0
- package/src/claude.test.ts +1 -1
- package/src/claude.ts +1 -1
- package/src/cli.test.ts +7 -7
- package/src/cli.ts +3 -3
- package/src/index.ts +1 -1
- package/src/orchestrator.test.ts +25 -25
- package/src/orchestrator.ts +10 -6
- package/src/prompt-builder.test.ts +28 -3
- package/src/prompt-builder.ts +20 -11
- package/src/scheduler.test.ts +36 -10
- package/src/scheduler.ts +7 -6
- package/src/sessions.test.ts +85 -19
- package/src/sessions.ts +33 -5
- package/src/settings.test.ts +40 -16
- package/src/settings.ts +29 -5
- package/src/setup.test.ts +12 -6
- package/src/setup.ts +8 -7
- package/workspace-template/.claude/skills/{schedule → schedule-event}/SKILL.md +29 -8
- package/workspace-template/CLAUDE.md +3 -1
- package/workspace-template/data/schedule.json +2 -0
package/src/scheduler.test.ts
CHANGED
|
@@ -17,7 +17,7 @@ function readScheduleConfig() {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function makeOnJob() {
|
|
20
|
-
return mock((_name: string, _prompt: string, _model?: string, _missed?: { missedBy: string; scheduledAt: string }) => {});
|
|
20
|
+
return mock((_name: string, _prompt: string, _model?: string, _missed?: { missedBy: string; scheduledAt: string }, _chat?: string) => {});
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
// Build a cron expression that matches the current minute
|
|
@@ -77,7 +77,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
77
77
|
s.start();
|
|
78
78
|
s.stop();
|
|
79
79
|
|
|
80
|
-
expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined);
|
|
80
|
+
expect(onJob).toHaveBeenCalledWith("test-job", "do something", undefined, undefined, undefined);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it("does not call onJob for non-matching jobs", () => {
|
|
@@ -107,7 +107,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
107
107
|
s.stop();
|
|
108
108
|
|
|
109
109
|
expect(onJob).toHaveBeenCalledTimes(1);
|
|
110
|
-
expect(onJob).toHaveBeenCalledWith("good", "good", undefined);
|
|
110
|
+
expect(onJob).toHaveBeenCalledWith("good", "good", undefined, undefined, undefined);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
it("handles multiple matching jobs", () => {
|
|
@@ -124,8 +124,8 @@ describe("Scheduler — cron jobs", () => {
|
|
|
124
124
|
s.stop();
|
|
125
125
|
|
|
126
126
|
expect(onJob).toHaveBeenCalledTimes(2);
|
|
127
|
-
expect(onJob).toHaveBeenCalledWith("first", "first", undefined);
|
|
128
|
-
expect(onJob).toHaveBeenCalledWith("second", "second", undefined);
|
|
127
|
+
expect(onJob).toHaveBeenCalledWith("first", "first", undefined, undefined, undefined);
|
|
128
|
+
expect(onJob).toHaveBeenCalledWith("second", "second", undefined, undefined, undefined);
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
it("passes model override to onJob", () => {
|
|
@@ -138,7 +138,20 @@ describe("Scheduler — cron jobs", () => {
|
|
|
138
138
|
s.start();
|
|
139
139
|
s.stop();
|
|
140
140
|
|
|
141
|
-
expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus");
|
|
141
|
+
expect(onJob).toHaveBeenCalledWith("smart", "think hard", "opus", undefined, undefined);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("passes chat field to onJob for cron jobs", () => {
|
|
145
|
+
writeScheduleConfig({
|
|
146
|
+
jobs: [{ name: "targeted-cron", cron: currentMinuteCron(), prompt: "family update", chat: "family" }],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const onJob = makeOnJob();
|
|
150
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
151
|
+
s.start();
|
|
152
|
+
s.stop();
|
|
153
|
+
|
|
154
|
+
expect(onJob).toHaveBeenCalledWith("targeted-cron", "family update", undefined, undefined, "family");
|
|
142
155
|
});
|
|
143
156
|
|
|
144
157
|
it("never removes cron jobs after firing", () => {
|
|
@@ -168,7 +181,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
168
181
|
s.start();
|
|
169
182
|
s.stop();
|
|
170
183
|
|
|
171
|
-
expect(onJob).toHaveBeenCalledWith("now", "do it", undefined);
|
|
184
|
+
expect(onJob).toHaveBeenCalledWith("now", "do it", undefined, undefined, undefined);
|
|
172
185
|
expect(onJob.mock.calls[0][3]).toBeUndefined();
|
|
173
186
|
});
|
|
174
187
|
|
|
@@ -294,7 +307,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
294
307
|
s.start();
|
|
295
308
|
s.stop();
|
|
296
309
|
|
|
297
|
-
expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
|
|
310
|
+
expect(onJob).toHaveBeenCalledWith("smart", "think", "opus", undefined, undefined);
|
|
298
311
|
});
|
|
299
312
|
|
|
300
313
|
it("interprets offset-less fireAt in the configured time zone", () => {
|
|
@@ -316,7 +329,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
316
329
|
s.start();
|
|
317
330
|
s.stop();
|
|
318
331
|
|
|
319
|
-
expect(onJob).toHaveBeenCalledWith("local", "local time", undefined);
|
|
332
|
+
expect(onJob).toHaveBeenCalledWith("local", "local time", undefined, undefined, undefined);
|
|
320
333
|
});
|
|
321
334
|
|
|
322
335
|
it("preserves explicit offset in fireAt (ignores configured time zone)", () => {
|
|
@@ -330,7 +343,20 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
330
343
|
s.start();
|
|
331
344
|
s.stop();
|
|
332
345
|
|
|
333
|
-
expect(onJob).toHaveBeenCalledWith("explicit", "with offset", undefined);
|
|
346
|
+
expect(onJob).toHaveBeenCalledWith("explicit", "with offset", undefined, undefined, undefined);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("passes chat field to onJob", () => {
|
|
350
|
+
writeScheduleConfig({
|
|
351
|
+
jobs: [{ name: "targeted", fireAt: justNowFireAt(), prompt: "hi family", chat: "family" }],
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const onJob = makeOnJob();
|
|
355
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
356
|
+
s.start();
|
|
357
|
+
s.stop();
|
|
358
|
+
|
|
359
|
+
expect(onJob).toHaveBeenCalledWith("targeted", "hi family", undefined, undefined, "family");
|
|
334
360
|
});
|
|
335
361
|
|
|
336
362
|
it("still fires when write-back of schedule.json fails", () => {
|
package/src/scheduler.ts
CHANGED
|
@@ -13,6 +13,7 @@ const jobSchema = z.object({
|
|
|
13
13
|
model: z.string().optional(),
|
|
14
14
|
cron: z.string().optional(),
|
|
15
15
|
fireAt: z.string().optional(),
|
|
16
|
+
chat: z.string().optional(),
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
const scheduleConfigSchema = z.object({
|
|
@@ -29,7 +30,7 @@ export interface MissedInfo {
|
|
|
29
30
|
|
|
30
31
|
export interface SchedulerConfig {
|
|
31
32
|
timeZone: string;
|
|
32
|
-
onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo) => void;
|
|
33
|
+
onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo, chat?: string) => void;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
const TICK_INTERVAL = 10_000; // 10 seconds
|
|
@@ -118,14 +119,14 @@ export class Scheduler {
|
|
|
118
119
|
}
|
|
119
120
|
}
|
|
120
121
|
|
|
121
|
-
#evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
|
|
122
|
+
#evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string; chat?: string }, now: Date): void {
|
|
122
123
|
try {
|
|
123
124
|
const interval = CronExpressionParser.parse(job.cron, { tz: this.#timeZone });
|
|
124
125
|
const prev = interval.prev();
|
|
125
126
|
const diff = Math.abs(now.getTime() - prev.getTime());
|
|
126
127
|
if (diff < 60_000) {
|
|
127
128
|
log.debug({ name: job.name, cron: job.cron }, "Cron job triggered");
|
|
128
|
-
this.#config.onJob(job.name, job.prompt, job.model);
|
|
129
|
+
this.#config.onJob(job.name, job.prompt, job.model, undefined, job.chat);
|
|
129
130
|
}
|
|
130
131
|
} catch (err) {
|
|
131
132
|
log.warn({ cron: job.cron, err: err instanceof Error ? err.message : err }, "Invalid cron expression");
|
|
@@ -144,7 +145,7 @@ export class Scheduler {
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
#evaluateFireAtJob(
|
|
147
|
-
job: { name: string; fireAt: string; prompt: string; model?: string },
|
|
148
|
+
job: { name: string; fireAt: string; prompt: string; model?: string; chat?: string },
|
|
148
149
|
now: Date,
|
|
149
150
|
): "remove" | "keep" {
|
|
150
151
|
const fireAt = Scheduler.#parseFireAt(job.fireAt, this.#timeZone);
|
|
@@ -162,7 +163,7 @@ export class Scheduler {
|
|
|
162
163
|
|
|
163
164
|
if (diff < 60_000) {
|
|
164
165
|
log.debug({ name: job.name, fireAt: job.fireAt }, "One-shot job triggered");
|
|
165
|
-
this.#config.onJob(job.name, job.prompt, job.model);
|
|
166
|
+
this.#config.onJob(job.name, job.prompt, job.model, undefined, job.chat);
|
|
166
167
|
return "remove";
|
|
167
168
|
}
|
|
168
169
|
|
|
@@ -172,7 +173,7 @@ export class Scheduler {
|
|
|
172
173
|
this.#config.onJob(job.name, job.prompt, job.model, {
|
|
173
174
|
missedBy: `${missedMinutes}m`,
|
|
174
175
|
scheduledAt: job.fireAt,
|
|
175
|
-
});
|
|
176
|
+
}, job.chat);
|
|
176
177
|
return "remove";
|
|
177
178
|
}
|
|
178
179
|
|
package/src/sessions.test.ts
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
2
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clearMainSession,
|
|
6
|
+
getMainSession,
|
|
7
|
+
loadSessions,
|
|
8
|
+
newSessionId,
|
|
9
|
+
saveSessions,
|
|
10
|
+
setMainSession,
|
|
11
|
+
} from "./sessions";
|
|
5
12
|
|
|
6
13
|
const tmpDir = "/tmp/macroclaw-sessions-test";
|
|
7
14
|
|
|
@@ -13,46 +20,105 @@ beforeEach(cleanup);
|
|
|
13
20
|
afterEach(cleanup);
|
|
14
21
|
|
|
15
22
|
describe("loadSessions", () => {
|
|
16
|
-
it("returns empty
|
|
17
|
-
expect(loadSessions(tmpDir)).toEqual({});
|
|
23
|
+
it("returns empty sessions when dir does not exist", () => {
|
|
24
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
|
|
18
25
|
});
|
|
19
26
|
|
|
20
|
-
it("returns empty
|
|
27
|
+
it("returns empty sessions when file does not exist", () => {
|
|
21
28
|
mkdirSync(tmpDir, { recursive: true });
|
|
22
|
-
expect(loadSessions(tmpDir)).toEqual({});
|
|
29
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
|
|
23
30
|
});
|
|
24
31
|
|
|
25
32
|
it("reads sessions from file", () => {
|
|
26
33
|
mkdirSync(tmpDir, { recursive: true });
|
|
27
|
-
writeFileSync(
|
|
28
|
-
|
|
34
|
+
writeFileSync(
|
|
35
|
+
join(tmpDir, "sessions.json"),
|
|
36
|
+
JSON.stringify({ mainSessions: { admin: "abc-123", family: "def-456" } }),
|
|
37
|
+
);
|
|
38
|
+
expect(loadSessions(tmpDir)).toEqual({
|
|
39
|
+
mainSessions: { admin: "abc-123", family: "def-456" },
|
|
40
|
+
});
|
|
29
41
|
});
|
|
30
42
|
|
|
31
|
-
it("returns empty
|
|
43
|
+
it("returns empty sessions when file is corrupt", () => {
|
|
32
44
|
mkdirSync(tmpDir, { recursive: true });
|
|
33
45
|
writeFileSync(join(tmpDir, "sessions.json"), "not json");
|
|
34
|
-
expect(loadSessions(tmpDir)).toEqual({});
|
|
46
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
|
|
35
47
|
});
|
|
36
48
|
|
|
37
49
|
it("strips unknown fields via schema", () => {
|
|
38
50
|
mkdirSync(tmpDir, { recursive: true });
|
|
39
|
-
writeFileSync(
|
|
40
|
-
|
|
41
|
-
|
|
51
|
+
writeFileSync(
|
|
52
|
+
join(tmpDir, "sessions.json"),
|
|
53
|
+
JSON.stringify({ mainSessions: { admin: "abc" }, extra: true }),
|
|
54
|
+
);
|
|
55
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "abc" } });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("migrates legacy mainSessionId to mainSessions.admin", () => {
|
|
59
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
60
|
+
writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({ mainSessionId: "legacy-id" }));
|
|
61
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "legacy-id" } });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("defaults to empty mainSessions when field is missing", () => {
|
|
65
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
66
|
+
writeFileSync(join(tmpDir, "sessions.json"), JSON.stringify({}));
|
|
67
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: {} });
|
|
42
68
|
});
|
|
43
69
|
});
|
|
44
70
|
|
|
45
71
|
describe("saveSessions", () => {
|
|
46
72
|
it("creates directory and writes file", () => {
|
|
47
|
-
saveSessions({
|
|
48
|
-
|
|
49
|
-
expect(saved).toEqual({ mainSessionId: "new-id" });
|
|
73
|
+
saveSessions({ mainSessions: { admin: "new-id" } }, tmpDir);
|
|
74
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "new-id" } });
|
|
50
75
|
});
|
|
51
76
|
|
|
52
77
|
it("overwrites existing file", () => {
|
|
53
|
-
saveSessions({
|
|
54
|
-
saveSessions({
|
|
55
|
-
expect(loadSessions(tmpDir)).toEqual({
|
|
78
|
+
saveSessions({ mainSessions: { admin: "first" } }, tmpDir);
|
|
79
|
+
saveSessions({ mainSessions: { admin: "second" } }, tmpDir);
|
|
80
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "second" } });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("getMainSession / setMainSession / clearMainSession", () => {
|
|
85
|
+
it("returns undefined when no session for chat", () => {
|
|
86
|
+
expect(getMainSession("admin", tmpDir)).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("returns stored session id", () => {
|
|
90
|
+
saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
|
|
91
|
+
expect(getMainSession("admin", tmpDir)).toBe("abc");
|
|
92
|
+
expect(getMainSession("family", tmpDir)).toBe("def");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("setMainSession preserves other chats", () => {
|
|
96
|
+
saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
|
|
97
|
+
setMainSession("admin", "new-admin-id", tmpDir);
|
|
98
|
+
expect(loadSessions(tmpDir)).toEqual({
|
|
99
|
+
mainSessions: { admin: "new-admin-id", family: "def" },
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("setMainSession adds new chat without touching others", () => {
|
|
104
|
+
saveSessions({ mainSessions: { admin: "abc" } }, tmpDir);
|
|
105
|
+
setMainSession("work", "work-id", tmpDir);
|
|
106
|
+
expect(loadSessions(tmpDir)).toEqual({
|
|
107
|
+
mainSessions: { admin: "abc", work: "work-id" },
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("clearMainSession removes one chat, preserves others", () => {
|
|
112
|
+
saveSessions({ mainSessions: { admin: "abc", family: "def" } }, tmpDir);
|
|
113
|
+
clearMainSession("family", tmpDir);
|
|
114
|
+
expect(loadSessions(tmpDir)).toEqual({ mainSessions: { admin: "abc" } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("clearMainSession is a no-op when chat is not present", () => {
|
|
118
|
+
saveSessions({ mainSessions: { admin: "abc" } }, tmpDir);
|
|
119
|
+
clearMainSession("nobody", tmpDir);
|
|
120
|
+
const contents = readFileSync(join(tmpDir, "sessions.json"), "utf-8");
|
|
121
|
+
expect(JSON.parse(contents)).toEqual({ mainSessions: { admin: "abc" } });
|
|
56
122
|
});
|
|
57
123
|
});
|
|
58
124
|
|
package/src/sessions.ts
CHANGED
|
@@ -7,22 +7,32 @@ import { createLogger } from "./logger";
|
|
|
7
7
|
const log = createLogger("sessions");
|
|
8
8
|
|
|
9
9
|
const sessionsSchema = z.object({
|
|
10
|
-
|
|
10
|
+
mainSessions: z.record(z.string(), z.string()).default({}),
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
export type Sessions = z.infer<typeof sessionsSchema>;
|
|
14
14
|
|
|
15
15
|
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
16
16
|
|
|
17
|
+
function migrateLegacy(raw: unknown): unknown {
|
|
18
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
19
|
+
const obj = raw as Record<string, unknown>;
|
|
20
|
+
if (typeof obj.mainSessionId === "string" && obj.mainSessions === undefined) {
|
|
21
|
+
return { mainSessions: { admin: obj.mainSessionId } };
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
export function loadSessions(dir: string = defaultDir): Sessions {
|
|
18
28
|
try {
|
|
19
29
|
const path = join(dir, "sessions.json");
|
|
20
|
-
if (!existsSync(path)) return {};
|
|
21
|
-
const raw = readFileSync(path, "utf-8");
|
|
22
|
-
return sessionsSchema.parse(
|
|
30
|
+
if (!existsSync(path)) return { mainSessions: {} };
|
|
31
|
+
const raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
|
|
32
|
+
return sessionsSchema.parse(raw);
|
|
23
33
|
} catch (err) {
|
|
24
34
|
log.warn({ err }, "Failed to load sessions.json, resetting to empty");
|
|
25
|
-
return {};
|
|
35
|
+
return { mainSessions: {} };
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -31,6 +41,24 @@ export function saveSessions(sessions: Sessions, dir: string = defaultDir): void
|
|
|
31
41
|
writeFileSync(join(dir, "sessions.json"), `${JSON.stringify(sessions, null, 2)}\n`);
|
|
32
42
|
}
|
|
33
43
|
|
|
44
|
+
export function getMainSession(chatName: string, dir: string = defaultDir): string | undefined {
|
|
45
|
+
return loadSessions(dir).mainSessions[chatName];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function setMainSession(chatName: string, sessionId: string, dir: string = defaultDir): void {
|
|
49
|
+
const sessions = loadSessions(dir);
|
|
50
|
+
sessions.mainSessions[chatName] = sessionId;
|
|
51
|
+
saveSessions(sessions, dir);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function clearMainSession(chatName: string, dir: string = defaultDir): void {
|
|
55
|
+
const sessions = loadSessions(dir);
|
|
56
|
+
if (chatName in sessions.mainSessions) {
|
|
57
|
+
delete sessions.mainSessions[chatName];
|
|
58
|
+
saveSessions(sessions, dir);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
export function newSessionId(): string {
|
|
35
63
|
return randomUUID();
|
|
36
64
|
}
|
package/src/settings.test.ts
CHANGED
|
@@ -7,7 +7,7 @@ const tmpDir = "/tmp/macroclaw-settings-test";
|
|
|
7
7
|
|
|
8
8
|
const validSettings: Settings = {
|
|
9
9
|
botToken: "123:ABC",
|
|
10
|
-
|
|
10
|
+
adminChatId: "12345678",
|
|
11
11
|
model: "sonnet",
|
|
12
12
|
workspace: "~/.macroclaw-workspace",
|
|
13
13
|
timeZone: "UTC",
|
|
@@ -32,12 +32,12 @@ describe("SettingsManager.load", () => {
|
|
|
32
32
|
mkdirSync(tmpDir, { recursive: true });
|
|
33
33
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
34
34
|
botToken: "123:ABC",
|
|
35
|
-
|
|
35
|
+
adminChatId: "12345678",
|
|
36
36
|
}));
|
|
37
37
|
const settings = new SettingsManager(tmpDir).load();
|
|
38
38
|
expect(settings).toEqual({
|
|
39
39
|
botToken: "123:ABC",
|
|
40
|
-
|
|
40
|
+
adminChatId: "12345678",
|
|
41
41
|
model: "sonnet",
|
|
42
42
|
workspace: "~/.macroclaw-workspace",
|
|
43
43
|
timeZone: "UTC",
|
|
@@ -49,7 +49,7 @@ describe("SettingsManager.load", () => {
|
|
|
49
49
|
mkdirSync(tmpDir, { recursive: true });
|
|
50
50
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
51
51
|
botToken: " 123:ABC ",
|
|
52
|
-
|
|
52
|
+
adminChatId: " 12345678 ",
|
|
53
53
|
model: " opus ",
|
|
54
54
|
workspace: " /custom/workspace ",
|
|
55
55
|
timeZone: " Europe/Prague ",
|
|
@@ -60,7 +60,7 @@ describe("SettingsManager.load", () => {
|
|
|
60
60
|
const settings = new SettingsManager(tmpDir).load();
|
|
61
61
|
expect(settings).toEqual({
|
|
62
62
|
botToken: "123:ABC",
|
|
63
|
-
|
|
63
|
+
adminChatId: "12345678",
|
|
64
64
|
model: "opus",
|
|
65
65
|
workspace: "/custom/workspace",
|
|
66
66
|
timeZone: "Europe/Prague",
|
|
@@ -74,7 +74,7 @@ describe("SettingsManager.load", () => {
|
|
|
74
74
|
mkdirSync(tmpDir, { recursive: true });
|
|
75
75
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
76
76
|
botToken: "tok",
|
|
77
|
-
|
|
77
|
+
adminChatId: "123",
|
|
78
78
|
model: "opus",
|
|
79
79
|
workspace: "/custom",
|
|
80
80
|
openaiApiKey: "sk-test",
|
|
@@ -84,7 +84,7 @@ describe("SettingsManager.load", () => {
|
|
|
84
84
|
const settings = new SettingsManager(tmpDir).load();
|
|
85
85
|
expect(settings).toEqual({
|
|
86
86
|
botToken: "tok",
|
|
87
|
-
|
|
87
|
+
adminChatId: "123",
|
|
88
88
|
model: "opus",
|
|
89
89
|
workspace: "/custom",
|
|
90
90
|
timeZone: "UTC",
|
|
@@ -114,7 +114,7 @@ describe("SettingsManager.load", () => {
|
|
|
114
114
|
|
|
115
115
|
it("exits with code 1 when validation fails (missing required field)", () => {
|
|
116
116
|
mkdirSync(tmpDir, { recursive: true });
|
|
117
|
-
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
117
|
+
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ adminChatId: "123" }));
|
|
118
118
|
|
|
119
119
|
const mockExit = mock(() => { throw new Error("exit"); });
|
|
120
120
|
const origExit = process.exit;
|
|
@@ -134,7 +134,7 @@ describe("SettingsManager.load", () => {
|
|
|
134
134
|
mkdirSync(tmpDir, { recursive: true });
|
|
135
135
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
136
136
|
botToken: "tok",
|
|
137
|
-
|
|
137
|
+
adminChatId: "123",
|
|
138
138
|
logLevel: "verbose",
|
|
139
139
|
}));
|
|
140
140
|
|
|
@@ -156,7 +156,7 @@ describe("SettingsManager.load", () => {
|
|
|
156
156
|
mkdirSync(tmpDir, { recursive: true });
|
|
157
157
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
158
158
|
botToken: "tok",
|
|
159
|
-
|
|
159
|
+
adminChatId: "123",
|
|
160
160
|
timeZone: "Europe/Prgaaue",
|
|
161
161
|
}));
|
|
162
162
|
|
|
@@ -199,8 +199,14 @@ describe("SettingsManager.loadRaw", () => {
|
|
|
199
199
|
|
|
200
200
|
it("reads and parses valid settings file", () => {
|
|
201
201
|
mkdirSync(tmpDir, { recursive: true });
|
|
202
|
-
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok",
|
|
203
|
-
expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok",
|
|
202
|
+
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", adminChatId: "123" }));
|
|
203
|
+
expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", adminChatId: "123" });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("migrates legacy chatId to adminChatId", () => {
|
|
207
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
208
|
+
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({ botToken: "tok", chatId: "legacy-id" }));
|
|
209
|
+
expect(new SettingsManager(tmpDir).loadRaw()).toEqual({ botToken: "tok", adminChatId: "legacy-id" });
|
|
204
210
|
});
|
|
205
211
|
|
|
206
212
|
it("returns null for invalid JSON", () => {
|
|
@@ -218,7 +224,7 @@ describe("SettingsManager.loadRaw", () => {
|
|
|
218
224
|
|
|
219
225
|
describe("SettingsManager.applyEnvOverrides", () => {
|
|
220
226
|
const envVars = [
|
|
221
|
-
"TELEGRAM_BOT_TOKEN", "AUTHORIZED_CHAT_ID", "MODEL",
|
|
227
|
+
"TELEGRAM_BOT_TOKEN", "ADMIN_CHAT_ID", "AUTHORIZED_CHAT_ID", "MODEL",
|
|
222
228
|
"WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL", "PINORAMA_URL",
|
|
223
229
|
];
|
|
224
230
|
const savedEnv: Record<string, string | undefined> = {};
|
|
@@ -252,15 +258,29 @@ describe("SettingsManager.applyEnvOverrides", () => {
|
|
|
252
258
|
|
|
253
259
|
it("overrides multiple fields and tracks them", () => {
|
|
254
260
|
process.env.TELEGRAM_BOT_TOKEN = "override-token";
|
|
255
|
-
process.env.
|
|
261
|
+
process.env.ADMIN_CHAT_ID = "99999";
|
|
256
262
|
process.env.OPENAI_API_KEY = "sk-override";
|
|
257
263
|
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
258
264
|
expect(settings.botToken).toBe("override-token");
|
|
259
|
-
expect(settings.
|
|
265
|
+
expect(settings.adminChatId).toBe("99999");
|
|
260
266
|
expect(settings.openaiApiKey).toBe("sk-override");
|
|
261
267
|
expect(overrides.size).toBe(3);
|
|
262
268
|
});
|
|
263
269
|
|
|
270
|
+
it("falls back to legacy AUTHORIZED_CHAT_ID when ADMIN_CHAT_ID is unset", () => {
|
|
271
|
+
process.env.AUTHORIZED_CHAT_ID = "88888";
|
|
272
|
+
const { settings, overrides } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
273
|
+
expect(settings.adminChatId).toBe("88888");
|
|
274
|
+
expect(overrides.has("adminChatId")).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("prefers ADMIN_CHAT_ID over legacy AUTHORIZED_CHAT_ID when both set", () => {
|
|
278
|
+
process.env.ADMIN_CHAT_ID = "111";
|
|
279
|
+
process.env.AUTHORIZED_CHAT_ID = "222";
|
|
280
|
+
const { settings } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
281
|
+
expect(settings.adminChatId).toBe("111");
|
|
282
|
+
});
|
|
283
|
+
|
|
264
284
|
it("overrides workspace and log-related fields", () => {
|
|
265
285
|
process.env.WORKSPACE = "/override/path";
|
|
266
286
|
process.env.LOG_LEVEL = "error";
|
|
@@ -332,7 +352,11 @@ describe("SettingsManager.print", () => {
|
|
|
332
352
|
describe("SettingsManager.envMapping", () => {
|
|
333
353
|
it("is a static property with all settings keys", () => {
|
|
334
354
|
expect(SettingsManager.envMapping.botToken).toBe("TELEGRAM_BOT_TOKEN");
|
|
335
|
-
expect(SettingsManager.envMapping.
|
|
355
|
+
expect(SettingsManager.envMapping.adminChatId).toBe("ADMIN_CHAT_ID");
|
|
336
356
|
expect(SettingsManager.envMapping.model).toBe("MODEL");
|
|
337
357
|
});
|
|
358
|
+
|
|
359
|
+
it("maps legacy AUTHORIZED_CHAT_ID to adminChatId for migration", () => {
|
|
360
|
+
expect(SettingsManager.envLegacy.adminChatId).toBe("AUTHORIZED_CHAT_ID");
|
|
361
|
+
});
|
|
338
362
|
});
|
package/src/settings.ts
CHANGED
|
@@ -8,7 +8,7 @@ const log = createLogger("settings");
|
|
|
8
8
|
|
|
9
9
|
export const settingsSchema = z.object({
|
|
10
10
|
botToken: z.string().trim(),
|
|
11
|
-
|
|
11
|
+
adminChatId: z.string().trim().regex(/^-?\d+$/, "Must be a numeric Telegram chat ID"),
|
|
12
12
|
model: z.string().trim().pipe(z.enum(["haiku", "sonnet", "opus"])).default("sonnet"),
|
|
13
13
|
workspace: z.string().trim().default("~/.macroclaw-workspace"),
|
|
14
14
|
timeZone: z.string().trim().refine((tz) => IANAZone.isValidZone(tz), "Must be a valid IANA timezone").default("UTC"),
|
|
@@ -29,12 +29,24 @@ export function maskValue(key: string, value: string | undefined): string {
|
|
|
29
29
|
|
|
30
30
|
const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
|
|
31
31
|
|
|
32
|
+
function migrateLegacy(raw: unknown): unknown {
|
|
33
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
34
|
+
const obj = raw as Record<string, unknown>;
|
|
35
|
+
if (typeof obj.chatId === "string" && obj.adminChatId === undefined) {
|
|
36
|
+
const { chatId, ...rest } = obj;
|
|
37
|
+
log.warn("settings.json uses legacy `chatId` field; migrating to `adminChatId`");
|
|
38
|
+
return { ...rest, adminChatId: chatId };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return raw;
|
|
42
|
+
}
|
|
43
|
+
|
|
32
44
|
export class SettingsManager {
|
|
33
45
|
readonly #dir: string;
|
|
34
46
|
|
|
35
47
|
static readonly envMapping: Record<keyof Settings, string> = {
|
|
36
48
|
botToken: "TELEGRAM_BOT_TOKEN",
|
|
37
|
-
|
|
49
|
+
adminChatId: "ADMIN_CHAT_ID",
|
|
38
50
|
model: "MODEL",
|
|
39
51
|
workspace: "WORKSPACE",
|
|
40
52
|
timeZone: "TIMEZONE",
|
|
@@ -43,6 +55,11 @@ export class SettingsManager {
|
|
|
43
55
|
pinoramaUrl: "PINORAMA_URL",
|
|
44
56
|
};
|
|
45
57
|
|
|
58
|
+
/** Legacy env var names still honored as fallbacks for one release. */
|
|
59
|
+
static readonly envLegacy: Partial<Record<keyof Settings, string>> = {
|
|
60
|
+
adminChatId: "AUTHORIZED_CHAT_ID",
|
|
61
|
+
};
|
|
62
|
+
|
|
46
63
|
constructor(dir: string = defaultDir) {
|
|
47
64
|
this.#dir = dir;
|
|
48
65
|
}
|
|
@@ -60,7 +77,7 @@ export class SettingsManager {
|
|
|
60
77
|
|
|
61
78
|
let raw: unknown;
|
|
62
79
|
try {
|
|
63
|
-
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
80
|
+
raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
|
|
64
81
|
} catch {
|
|
65
82
|
raw = null;
|
|
66
83
|
}
|
|
@@ -78,8 +95,8 @@ export class SettingsManager {
|
|
|
78
95
|
const path = join(this.#dir, "settings.json");
|
|
79
96
|
if (!existsSync(path)) return null;
|
|
80
97
|
try {
|
|
81
|
-
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
82
|
-
return typeof raw === "object" && raw !== null ? raw : null;
|
|
98
|
+
const raw = migrateLegacy(JSON.parse(readFileSync(path, "utf-8")));
|
|
99
|
+
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : null;
|
|
83
100
|
} catch {
|
|
84
101
|
return null;
|
|
85
102
|
}
|
|
@@ -99,6 +116,13 @@ export class SettingsManager {
|
|
|
99
116
|
if (value !== undefined) {
|
|
100
117
|
merged[key] = value;
|
|
101
118
|
overrides.add(key);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const legacy = SettingsManager.envLegacy[key as keyof Settings];
|
|
122
|
+
if (legacy && process.env[legacy] !== undefined) {
|
|
123
|
+
merged[key] = process.env[legacy];
|
|
124
|
+
overrides.add(key);
|
|
125
|
+
log.warn({ legacy, preferred: envVar }, "using legacy env var; rename to the new one");
|
|
102
126
|
}
|
|
103
127
|
}
|
|
104
128
|
|
package/src/setup.test.ts
CHANGED
|
@@ -46,6 +46,12 @@ mock.module("grammy", () => ({
|
|
|
46
46
|
await mockBotStop();
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
|
+
InlineKeyboard: class {
|
|
50
|
+
inline_keyboard: never[] = [];
|
|
51
|
+
text() { return this; }
|
|
52
|
+
row() { return this; }
|
|
53
|
+
},
|
|
54
|
+
InputFile: class { constructor(public path: string) {} },
|
|
49
55
|
}));
|
|
50
56
|
|
|
51
57
|
const mockInstall = mock(() => "tail -f /mock/logs");
|
|
@@ -79,7 +85,7 @@ function createMockIO(inputs: string[]): SetupIo & { written: string[] } {
|
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
// Save/restore env vars
|
|
82
|
-
const envVars = ["TELEGRAM_BOT_TOKEN", "
|
|
88
|
+
const envVars = ["TELEGRAM_BOT_TOKEN", "ADMIN_CHAT_ID", "MODEL", "WORKSPACE", "TIMEZONE", "OPENAI_API_KEY", "LOG_LEVEL"];
|
|
83
89
|
const savedEnv: Record<string, string | undefined> = {};
|
|
84
90
|
|
|
85
91
|
beforeEach(() => {
|
|
@@ -119,7 +125,7 @@ describe("SetupWizard", () => {
|
|
|
119
125
|
const settings = await runSetup(io);
|
|
120
126
|
|
|
121
127
|
expect(settings.botToken).toBe("123:ABC");
|
|
122
|
-
expect(settings.
|
|
128
|
+
expect(settings.adminChatId).toBe("12345678");
|
|
123
129
|
expect(settings.model).toBe("opus");
|
|
124
130
|
expect(settings.workspace).toBe("/my/ws");
|
|
125
131
|
expect(settings.timeZone).toBe("Europe/Prague");
|
|
@@ -129,7 +135,7 @@ describe("SetupWizard", () => {
|
|
|
129
135
|
|
|
130
136
|
it("uses defaults from env vars when user presses enter", async () => {
|
|
131
137
|
process.env.TELEGRAM_BOT_TOKEN = "env-token";
|
|
132
|
-
process.env.
|
|
138
|
+
process.env.ADMIN_CHAT_ID = "99887766";
|
|
133
139
|
process.env.MODEL = "haiku";
|
|
134
140
|
process.env.WORKSPACE = "/env/ws";
|
|
135
141
|
process.env.TIMEZONE = "America/New_York";
|
|
@@ -148,7 +154,7 @@ describe("SetupWizard", () => {
|
|
|
148
154
|
const settings = await runSetup(io);
|
|
149
155
|
|
|
150
156
|
expect(settings.botToken).toBe("env-token");
|
|
151
|
-
expect(settings.
|
|
157
|
+
expect(settings.adminChatId).toBe("99887766");
|
|
152
158
|
expect(settings.model).toBe("haiku");
|
|
153
159
|
expect(settings.workspace).toBe("/env/ws");
|
|
154
160
|
expect(settings.timeZone).toBe("America/New_York");
|
|
@@ -246,7 +252,7 @@ describe("SetupWizard", () => {
|
|
|
246
252
|
|
|
247
253
|
const settings = await runSetup(io);
|
|
248
254
|
|
|
249
|
-
expect(settings.
|
|
255
|
+
expect(settings.adminChatId).toBe("456");
|
|
250
256
|
});
|
|
251
257
|
|
|
252
258
|
it("re-prompts when chat ID is not numeric", async () => {
|
|
@@ -263,7 +269,7 @@ describe("SetupWizard", () => {
|
|
|
263
269
|
|
|
264
270
|
const settings = await runSetup(io);
|
|
265
271
|
|
|
266
|
-
expect(settings.
|
|
272
|
+
expect(settings.adminChatId).toBe("456");
|
|
267
273
|
expect(io.written).toContainEqual(expect.stringContaining("Invalid value"));
|
|
268
274
|
});
|
|
269
275
|
|