macroclaw 0.38.0 → 0.40.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 +2 -2
- package/package.json +1 -1
- package/src/app.test.ts +1 -1
- package/src/app.ts +3 -3
- package/src/index.ts +1 -1
- package/src/orchestrator.test.ts +2 -2
- package/src/orchestrator.ts +3 -3
- package/src/prompt-builder.test.ts +1 -1
- package/src/prompt-builder.ts +5 -5
- package/src/scheduler.test.ts +28 -28
- package/src/scheduler.ts +9 -9
- package/src/settings.test.ts +8 -8
- package/src/settings.ts +2 -2
- package/src/setup.test.ts +2 -2
- package/src/setup.ts +3 -3
- package/src/system-service.test.ts +25 -110
- package/src/system-service.ts +14 -48
- package/workspace-template/.claude/skills/settings/SKILL.md +4 -4
package/README.md
CHANGED
|
@@ -43,12 +43,12 @@ Settings are stored in `~/.macroclaw/settings.json` and validated on startup.
|
|
|
43
43
|
| `chatId` | `AUTHORIZED_CHAT_ID` | — | Yes |
|
|
44
44
|
| `model` | `MODEL` | `sonnet` | No |
|
|
45
45
|
| `workspace` | `WORKSPACE` | `~/.macroclaw-workspace` | No |
|
|
46
|
-
| `
|
|
46
|
+
| `timeZone` | `TIMEZONE` | `UTC` | No |
|
|
47
47
|
| `openaiApiKey` | `OPENAI_API_KEY` | — | No |
|
|
48
48
|
| `logLevel` | `LOG_LEVEL` | `info` | No |
|
|
49
49
|
| `pinoramaUrl` | `PINORAMA_URL` | — | No |
|
|
50
50
|
|
|
51
|
-
**`
|
|
51
|
+
**`timeZone`** sets the agent's local time zone (IANA format, e.g. `Europe/Prague`, `America/New_York`). Used for the agent's clock display and scheduled event timing.
|
|
52
52
|
|
|
53
53
|
**`openaiApiKey`** is used for voice message transcription via [OpenAI Whisper](https://platform.openai.com/docs/guides/speech-to-text). Without it, voice messages are ignored.
|
|
54
54
|
|
package/package.json
CHANGED
package/src/app.test.ts
CHANGED
|
@@ -133,7 +133,7 @@ function makeConfig(overrides?: Partial<AppConfig>): AppConfig {
|
|
|
133
133
|
authorizedChatId: "12345",
|
|
134
134
|
workspace: "/tmp/macroclaw-test-workspace",
|
|
135
135
|
model: "sonnet",
|
|
136
|
-
|
|
136
|
+
timeZone: "UTC",
|
|
137
137
|
settingsDir: tmpSettingsDir,
|
|
138
138
|
claude: defaultMockClaude(),
|
|
139
139
|
stt: mockStt(),
|
package/src/app.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface AppConfig {
|
|
|
12
12
|
authorizedChatId: string;
|
|
13
13
|
workspace: string;
|
|
14
14
|
model: string;
|
|
15
|
-
|
|
15
|
+
timeZone: string;
|
|
16
16
|
settingsDir?: string;
|
|
17
17
|
claude?: Claude;
|
|
18
18
|
stt?: SpeechToText;
|
|
@@ -30,7 +30,7 @@ export class App {
|
|
|
30
30
|
this.#orchestrator = new Orchestrator({
|
|
31
31
|
model: config.model,
|
|
32
32
|
workspace: config.workspace,
|
|
33
|
-
|
|
33
|
+
timeZone: config.timeZone,
|
|
34
34
|
settingsDir: config.settingsDir,
|
|
35
35
|
claude: config.claude,
|
|
36
36
|
healthCheckInterval: config.healthCheckInterval,
|
|
@@ -51,7 +51,7 @@ export class App {
|
|
|
51
51
|
start() {
|
|
52
52
|
log.info("Starting macroclaw...");
|
|
53
53
|
const scheduler = new Scheduler(this.#config.workspace, {
|
|
54
|
-
|
|
54
|
+
timeZone: this.#config.timeZone,
|
|
55
55
|
onJob: (name, prompt, model, missed) => this.#orchestrator.handleCron(name, prompt, model, missed),
|
|
56
56
|
});
|
|
57
57
|
scheduler.start();
|
package/src/index.ts
CHANGED
|
@@ -40,7 +40,7 @@ export async function start(): Promise<void> {
|
|
|
40
40
|
authorizedChatId: resolved.chatId,
|
|
41
41
|
workspace,
|
|
42
42
|
model: resolved.model,
|
|
43
|
-
|
|
43
|
+
timeZone: resolved.timeZone,
|
|
44
44
|
stt: resolved.openaiApiKey ? new SpeechToText(resolved.openaiApiKey) : undefined,
|
|
45
45
|
};
|
|
46
46
|
|
package/src/orchestrator.test.ts
CHANGED
|
@@ -117,7 +117,7 @@ function makeOrchestrator(claude: Claude, extraConfig?: Partial<OrchestratorConf
|
|
|
117
117
|
const orch = new Orchestrator({
|
|
118
118
|
workspace: TEST_WORKSPACE,
|
|
119
119
|
model: "sonnet",
|
|
120
|
-
|
|
120
|
+
timeZone: "UTC",
|
|
121
121
|
settingsDir: tmpSettingsDir,
|
|
122
122
|
onResponse,
|
|
123
123
|
claude,
|
|
@@ -1156,7 +1156,7 @@ describe("Orchestrator", () => {
|
|
|
1156
1156
|
const orch = new Orchestrator({
|
|
1157
1157
|
workspace: TEST_WORKSPACE,
|
|
1158
1158
|
model: "sonnet",
|
|
1159
|
-
|
|
1159
|
+
timeZone: "UTC",
|
|
1160
1160
|
settingsDir: tmpSettingsDir,
|
|
1161
1161
|
onResponse: failingOnResponse,
|
|
1162
1162
|
claude,
|
package/src/orchestrator.ts
CHANGED
|
@@ -92,7 +92,7 @@ interface SessionInfo {
|
|
|
92
92
|
export interface OrchestratorConfig {
|
|
93
93
|
model: string;
|
|
94
94
|
workspace: string;
|
|
95
|
-
|
|
95
|
+
timeZone: string;
|
|
96
96
|
settingsDir?: string;
|
|
97
97
|
onResponse: (response: OrchestratorResponse) => Promise<void>;
|
|
98
98
|
claude?: Claude;
|
|
@@ -119,8 +119,8 @@ export class Orchestrator {
|
|
|
119
119
|
|
|
120
120
|
constructor(config: OrchestratorConfig) {
|
|
121
121
|
this.#config = config;
|
|
122
|
-
this.#prompts = new PromptBuilder(config.
|
|
123
|
-
const envVars: Record<string, string> = { TZ: config.
|
|
122
|
+
this.#prompts = new PromptBuilder(config.timeZone);
|
|
123
|
+
const envVars: Record<string, string> = { TZ: config.timeZone };
|
|
124
124
|
this.#claude = config.claude ?? new Claude({ workspace: config.workspace, systemPrompt: this.#prompts.systemPrompt, envVars });
|
|
125
125
|
this.#waitThreshold = config.waitThreshold ?? WAIT_THRESHOLD;
|
|
126
126
|
this.#healthCheckInterval = config.healthCheckInterval ?? HEALTH_CHECK_INTERVAL_MS;
|
|
@@ -51,7 +51,7 @@ describe("systemPrompt", () => {
|
|
|
51
51
|
expect(p.systemPrompt).toContain("opus");
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
it("includes
|
|
54
|
+
it("includes time zone but not a fixed date", () => {
|
|
55
55
|
const prague = new PromptBuilder("Europe/Prague");
|
|
56
56
|
expect(prague.systemPrompt).not.toContain("Current date:");
|
|
57
57
|
expect(prague.systemPrompt).toContain("Timezone: Europe/Prague");
|
package/src/prompt-builder.ts
CHANGED
|
@@ -82,18 +82,18 @@ interface BuildXmlFields {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
export class PromptBuilder {
|
|
85
|
-
readonly #
|
|
85
|
+
readonly #timeZone: string;
|
|
86
86
|
|
|
87
|
-
constructor(
|
|
88
|
-
this.#
|
|
87
|
+
constructor(timeZone: string) {
|
|
88
|
+
this.#timeZone = timeZone;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
get systemPrompt(): string {
|
|
92
|
-
return `${SYSTEM_PROMPT_BASE}\n\nTimezone: ${this.#
|
|
92
|
+
return `${SYSTEM_PROMPT_BASE}\n\nTimezone: ${this.#timeZone}. TZ env var is set — \`date\` and other CLI tools return local time.`;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
#localTime(): string {
|
|
96
|
-
return DateTime.now().setZone(this.#
|
|
96
|
+
return DateTime.now().setZone(this.#timeZone).toFormat("yyyy-MM-dd'T'HH:mm");
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
static #escapeXml(text: string): string {
|
package/src/scheduler.test.ts
CHANGED
|
@@ -73,7 +73,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
const onJob = makeOnJob();
|
|
76
|
-
const s = new Scheduler(TEST_DIR, {
|
|
76
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
77
77
|
s.start();
|
|
78
78
|
s.stop();
|
|
79
79
|
|
|
@@ -86,7 +86,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
86
86
|
});
|
|
87
87
|
|
|
88
88
|
const onJob = makeOnJob();
|
|
89
|
-
const s = new Scheduler(TEST_DIR, {
|
|
89
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
90
90
|
s.start();
|
|
91
91
|
s.stop();
|
|
92
92
|
|
|
@@ -102,7 +102,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
const onJob = makeOnJob();
|
|
105
|
-
const s = new Scheduler(TEST_DIR, {
|
|
105
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
106
106
|
s.start();
|
|
107
107
|
s.stop();
|
|
108
108
|
|
|
@@ -119,7 +119,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
const onJob = makeOnJob();
|
|
122
|
-
const s = new Scheduler(TEST_DIR, {
|
|
122
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
123
123
|
s.start();
|
|
124
124
|
s.stop();
|
|
125
125
|
|
|
@@ -134,7 +134,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
const onJob = makeOnJob();
|
|
137
|
-
const s = new Scheduler(TEST_DIR, {
|
|
137
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
138
138
|
s.start();
|
|
139
139
|
s.stop();
|
|
140
140
|
|
|
@@ -147,7 +147,7 @@ describe("Scheduler — cron jobs", () => {
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
const onJob = makeOnJob();
|
|
150
|
-
const s = new Scheduler(TEST_DIR, {
|
|
150
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
151
151
|
s.start();
|
|
152
152
|
s.stop();
|
|
153
153
|
|
|
@@ -164,7 +164,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
const onJob = makeOnJob();
|
|
167
|
-
const s = new Scheduler(TEST_DIR, {
|
|
167
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
168
168
|
s.start();
|
|
169
169
|
s.stop();
|
|
170
170
|
|
|
@@ -181,7 +181,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
const onJob = makeOnJob();
|
|
184
|
-
const s = new Scheduler(TEST_DIR, {
|
|
184
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
185
185
|
s.start();
|
|
186
186
|
s.stop();
|
|
187
187
|
|
|
@@ -197,7 +197,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
const onJob = makeOnJob();
|
|
200
|
-
const s = new Scheduler(TEST_DIR, {
|
|
200
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
201
201
|
s.start();
|
|
202
202
|
s.stop();
|
|
203
203
|
|
|
@@ -214,7 +214,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
const onJob = makeOnJob();
|
|
217
|
-
const s = new Scheduler(TEST_DIR, {
|
|
217
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
218
218
|
s.start();
|
|
219
219
|
s.stop();
|
|
220
220
|
|
|
@@ -236,7 +236,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
236
236
|
});
|
|
237
237
|
|
|
238
238
|
const onJob = makeOnJob();
|
|
239
|
-
const s = new Scheduler(TEST_DIR, {
|
|
239
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
240
240
|
s.start();
|
|
241
241
|
s.stop();
|
|
242
242
|
|
|
@@ -252,7 +252,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
252
252
|
});
|
|
253
253
|
|
|
254
254
|
const onJob = makeOnJob();
|
|
255
|
-
const s = new Scheduler(TEST_DIR, {
|
|
255
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
256
256
|
s.start();
|
|
257
257
|
s.stop();
|
|
258
258
|
|
|
@@ -273,7 +273,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
273
273
|
});
|
|
274
274
|
|
|
275
275
|
const onJob = makeOnJob();
|
|
276
|
-
const s = new Scheduler(TEST_DIR, {
|
|
276
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
277
277
|
s.start();
|
|
278
278
|
s.stop();
|
|
279
279
|
|
|
@@ -290,14 +290,14 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
290
290
|
});
|
|
291
291
|
|
|
292
292
|
const onJob = makeOnJob();
|
|
293
|
-
const s = new Scheduler(TEST_DIR, {
|
|
293
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
294
294
|
s.start();
|
|
295
295
|
s.stop();
|
|
296
296
|
|
|
297
297
|
expect(onJob).toHaveBeenCalledWith("smart", "think", "opus");
|
|
298
298
|
});
|
|
299
299
|
|
|
300
|
-
it("interprets offset-less fireAt in the configured
|
|
300
|
+
it("interprets offset-less fireAt in the configured time zone", () => {
|
|
301
301
|
// Create a fireAt 30 seconds ago in Europe/Prague local time (no offset)
|
|
302
302
|
const now = new Date();
|
|
303
303
|
const pragueStr = now.toLocaleString("en-US", { timeZone: "Europe/Prague" });
|
|
@@ -312,21 +312,21 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
312
312
|
});
|
|
313
313
|
|
|
314
314
|
const onJob = makeOnJob();
|
|
315
|
-
const s = new Scheduler(TEST_DIR, {
|
|
315
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "Europe/Prague", onJob });
|
|
316
316
|
s.start();
|
|
317
317
|
s.stop();
|
|
318
318
|
|
|
319
319
|
expect(onJob).toHaveBeenCalledWith("local", "local time", undefined);
|
|
320
320
|
});
|
|
321
321
|
|
|
322
|
-
it("preserves explicit offset in fireAt (ignores configured
|
|
322
|
+
it("preserves explicit offset in fireAt (ignores configured time zone)", () => {
|
|
323
323
|
// fireAt with explicit +00:00 offset, 30s ago — should fire regardless of configured tz
|
|
324
324
|
writeScheduleConfig({
|
|
325
325
|
jobs: [{ name: "explicit", fireAt: justNowFireAt(), prompt: "with offset" }],
|
|
326
326
|
});
|
|
327
327
|
|
|
328
328
|
const onJob = makeOnJob();
|
|
329
|
-
const s = new Scheduler(TEST_DIR, {
|
|
329
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "Asia/Tokyo", onJob });
|
|
330
330
|
s.start();
|
|
331
331
|
s.stop();
|
|
332
332
|
|
|
@@ -342,7 +342,7 @@ describe("Scheduler — fireAt jobs", () => {
|
|
|
342
342
|
chmodSync(SCHEDULE_FILE, 0o444);
|
|
343
343
|
|
|
344
344
|
const onJob = makeOnJob();
|
|
345
|
-
const s = new Scheduler(TEST_DIR, {
|
|
345
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
346
346
|
s.start();
|
|
347
347
|
s.stop();
|
|
348
348
|
|
|
@@ -357,7 +357,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
357
357
|
rmSync(SCHEDULE_FILE, { force: true });
|
|
358
358
|
|
|
359
359
|
const onJob = makeOnJob();
|
|
360
|
-
const s = new Scheduler(TEST_DIR, {
|
|
360
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
361
361
|
s.start();
|
|
362
362
|
s.stop();
|
|
363
363
|
|
|
@@ -368,7 +368,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
368
368
|
writeFileSync(SCHEDULE_FILE, "not json{{{");
|
|
369
369
|
|
|
370
370
|
const onJob = makeOnJob();
|
|
371
|
-
const s = new Scheduler(TEST_DIR, {
|
|
371
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
372
372
|
s.start();
|
|
373
373
|
s.stop();
|
|
374
374
|
|
|
@@ -379,7 +379,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
379
379
|
writeScheduleConfig({ jobs: "not-array" });
|
|
380
380
|
|
|
381
381
|
const onJob = makeOnJob();
|
|
382
|
-
const s = new Scheduler(TEST_DIR, {
|
|
382
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
383
383
|
s.start();
|
|
384
384
|
s.stop();
|
|
385
385
|
|
|
@@ -392,7 +392,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
392
392
|
});
|
|
393
393
|
|
|
394
394
|
const onJob = makeOnJob();
|
|
395
|
-
const s = new Scheduler(TEST_DIR, {
|
|
395
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
396
396
|
s.start();
|
|
397
397
|
s.stop();
|
|
398
398
|
|
|
@@ -405,7 +405,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
405
405
|
});
|
|
406
406
|
|
|
407
407
|
const onJob = makeOnJob();
|
|
408
|
-
const s = new Scheduler(TEST_DIR, {
|
|
408
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
409
409
|
s.start();
|
|
410
410
|
s.stop();
|
|
411
411
|
|
|
@@ -420,7 +420,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
420
420
|
writeScheduleConfig({ jobs: [] });
|
|
421
421
|
|
|
422
422
|
const onJob = makeOnJob();
|
|
423
|
-
const s = new Scheduler(TEST_DIR, {
|
|
423
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
424
424
|
s.start();
|
|
425
425
|
s.stop(); // should not throw
|
|
426
426
|
});
|
|
@@ -431,7 +431,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
431
431
|
});
|
|
432
432
|
|
|
433
433
|
const onJob = makeOnJob();
|
|
434
|
-
const s = new Scheduler(TEST_DIR, {
|
|
434
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
435
435
|
s.start();
|
|
436
436
|
s.stop();
|
|
437
437
|
|
|
@@ -439,7 +439,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
439
439
|
|
|
440
440
|
// Start again with a new instance — the lastMinute tracker is per-instance
|
|
441
441
|
const onJob2 = makeOnJob();
|
|
442
|
-
const s2 = new Scheduler(TEST_DIR, {
|
|
442
|
+
const s2 = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob: onJob2 });
|
|
443
443
|
s2.start();
|
|
444
444
|
s2.stop();
|
|
445
445
|
|
|
@@ -452,7 +452,7 @@ describe("Scheduler — validation and edge cases", () => {
|
|
|
452
452
|
});
|
|
453
453
|
|
|
454
454
|
const onJob = makeOnJob();
|
|
455
|
-
const s = new Scheduler(TEST_DIR, {
|
|
455
|
+
const s = new Scheduler(TEST_DIR, { timeZone: "UTC", onJob });
|
|
456
456
|
s.start();
|
|
457
457
|
s.stop();
|
|
458
458
|
|
package/src/scheduler.ts
CHANGED
|
@@ -28,7 +28,7 @@ export interface MissedInfo {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
export interface SchedulerConfig {
|
|
31
|
-
|
|
31
|
+
timeZone: string;
|
|
32
32
|
onJob: (name: string, prompt: string, model?: string, missed?: MissedInfo) => void;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -39,13 +39,13 @@ export class Scheduler {
|
|
|
39
39
|
#lastMinute = -1;
|
|
40
40
|
#schedulePath: string;
|
|
41
41
|
#config: SchedulerConfig;
|
|
42
|
-
#
|
|
42
|
+
#timeZone: string;
|
|
43
43
|
#timer: Timer | null = null;
|
|
44
44
|
|
|
45
45
|
constructor(workspace: string, config: SchedulerConfig) {
|
|
46
46
|
this.#schedulePath = join(workspace, "data", "schedule.json");
|
|
47
47
|
this.#config = config;
|
|
48
|
-
this.#
|
|
48
|
+
this.#timeZone = config.timeZone;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
start(): void {
|
|
@@ -120,7 +120,7 @@ export class Scheduler {
|
|
|
120
120
|
|
|
121
121
|
#evaluateCronJob(job: { name: string; cron: string; prompt: string; model?: string }, now: Date): void {
|
|
122
122
|
try {
|
|
123
|
-
const interval = CronExpressionParser.parse(job.cron, { tz: this.#
|
|
123
|
+
const interval = CronExpressionParser.parse(job.cron, { tz: this.#timeZone });
|
|
124
124
|
const prev = interval.prev();
|
|
125
125
|
const diff = Math.abs(now.getTime() - prev.getTime());
|
|
126
126
|
if (diff < 60_000) {
|
|
@@ -132,22 +132,22 @@ export class Scheduler {
|
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** Parse a fireAt string, interpreting offset-less timestamps in the given
|
|
136
|
-
static #parseFireAt(fireAt: string,
|
|
135
|
+
/** Parse a fireAt string, interpreting offset-less timestamps in the given time zone. */
|
|
136
|
+
static #parseFireAt(fireAt: string, timeZone: string): Date {
|
|
137
137
|
const probe = DateTime.fromISO(fireAt, { setZone: true });
|
|
138
138
|
if (probe.isValid && probe.isOffsetFixed) {
|
|
139
139
|
// Has explicit offset (Z, +HH:MM, etc.) — use as-is
|
|
140
140
|
return probe.toJSDate();
|
|
141
141
|
}
|
|
142
|
-
// No offset — interpret as local time in the configured
|
|
143
|
-
return DateTime.fromISO(fireAt, { zone:
|
|
142
|
+
// No offset — interpret as local time in the configured time zone
|
|
143
|
+
return DateTime.fromISO(fireAt, { zone: timeZone }).toJSDate();
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
#evaluateFireAtJob(
|
|
147
147
|
job: { name: string; fireAt: string; prompt: string; model?: string },
|
|
148
148
|
now: Date,
|
|
149
149
|
): "remove" | "keep" {
|
|
150
|
-
const fireAt = Scheduler.#parseFireAt(job.fireAt, this.#
|
|
150
|
+
const fireAt = Scheduler.#parseFireAt(job.fireAt, this.#timeZone);
|
|
151
151
|
if (Number.isNaN(fireAt.getTime())) {
|
|
152
152
|
log.warn({ name: job.name, fireAt: job.fireAt }, "Invalid fireAt date");
|
|
153
153
|
return "keep";
|
package/src/settings.test.ts
CHANGED
|
@@ -10,7 +10,7 @@ const validSettings: Settings = {
|
|
|
10
10
|
chatId: "12345678",
|
|
11
11
|
model: "sonnet",
|
|
12
12
|
workspace: "~/.macroclaw-workspace",
|
|
13
|
-
|
|
13
|
+
timeZone: "UTC",
|
|
14
14
|
logLevel: "debug",
|
|
15
15
|
};
|
|
16
16
|
|
|
@@ -40,7 +40,7 @@ describe("SettingsManager.load", () => {
|
|
|
40
40
|
chatId: "12345678",
|
|
41
41
|
model: "sonnet",
|
|
42
42
|
workspace: "~/.macroclaw-workspace",
|
|
43
|
-
|
|
43
|
+
timeZone: "UTC",
|
|
44
44
|
logLevel: "info",
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -52,7 +52,7 @@ describe("SettingsManager.load", () => {
|
|
|
52
52
|
chatId: " 12345678 ",
|
|
53
53
|
model: " opus ",
|
|
54
54
|
workspace: " /custom/workspace ",
|
|
55
|
-
|
|
55
|
+
timeZone: " Europe/Prague ",
|
|
56
56
|
openaiApiKey: " sk-test ",
|
|
57
57
|
logLevel: " warn ",
|
|
58
58
|
pinoramaUrl: " http://localhost:6200 ",
|
|
@@ -63,7 +63,7 @@ describe("SettingsManager.load", () => {
|
|
|
63
63
|
chatId: "12345678",
|
|
64
64
|
model: "opus",
|
|
65
65
|
workspace: "/custom/workspace",
|
|
66
|
-
|
|
66
|
+
timeZone: "Europe/Prague",
|
|
67
67
|
openaiApiKey: "sk-test",
|
|
68
68
|
logLevel: "warn",
|
|
69
69
|
pinoramaUrl: "http://localhost:6200",
|
|
@@ -87,7 +87,7 @@ describe("SettingsManager.load", () => {
|
|
|
87
87
|
chatId: "123",
|
|
88
88
|
model: "opus",
|
|
89
89
|
workspace: "/custom",
|
|
90
|
-
|
|
90
|
+
timeZone: "UTC",
|
|
91
91
|
openaiApiKey: "sk-test",
|
|
92
92
|
logLevel: "info",
|
|
93
93
|
pinoramaUrl: "http://localhost:6200",
|
|
@@ -152,12 +152,12 @@ describe("SettingsManager.load", () => {
|
|
|
152
152
|
process.exit = origExit;
|
|
153
153
|
});
|
|
154
154
|
|
|
155
|
-
it("exits with code 1 when validation fails (invalid
|
|
155
|
+
it("exits with code 1 when validation fails (invalid timeZone)", () => {
|
|
156
156
|
mkdirSync(tmpDir, { recursive: true });
|
|
157
157
|
writeFileSync(join(tmpDir, "settings.json"), JSON.stringify({
|
|
158
158
|
botToken: "tok",
|
|
159
159
|
chatId: "123",
|
|
160
|
-
|
|
160
|
+
timeZone: "Europe/Prgaaue",
|
|
161
161
|
}));
|
|
162
162
|
|
|
163
163
|
const mockExit = mock(() => { throw new Error("exit"); });
|
|
@@ -283,7 +283,7 @@ describe("SettingsManager.applyEnvOverrides", () => {
|
|
|
283
283
|
const { settings } = new SettingsManager(tmpDir).applyEnvOverrides(validSettings);
|
|
284
284
|
expect(settings.model).toBe("opus");
|
|
285
285
|
expect(settings.workspace).toBe("/override/path");
|
|
286
|
-
expect(settings.
|
|
286
|
+
expect(settings.timeZone).toBe("Europe/Prague");
|
|
287
287
|
expect(settings.logLevel).toBe("error");
|
|
288
288
|
expect(settings.pinoramaUrl).toBe("http://override:6200");
|
|
289
289
|
});
|
package/src/settings.ts
CHANGED
|
@@ -11,7 +11,7 @@ export const settingsSchema = z.object({
|
|
|
11
11
|
chatId: 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"),
|
|
15
15
|
openaiApiKey: z.string().trim().optional(),
|
|
16
16
|
logLevel: z.string().trim().pipe(z.enum(["debug", "info", "warn", "error"])).default("info"),
|
|
17
17
|
pinoramaUrl: z.string().trim().optional(),
|
|
@@ -37,7 +37,7 @@ export class SettingsManager {
|
|
|
37
37
|
chatId: "AUTHORIZED_CHAT_ID",
|
|
38
38
|
model: "MODEL",
|
|
39
39
|
workspace: "WORKSPACE",
|
|
40
|
-
|
|
40
|
+
timeZone: "TIMEZONE",
|
|
41
41
|
openaiApiKey: "OPENAI_API_KEY",
|
|
42
42
|
logLevel: "LOG_LEVEL",
|
|
43
43
|
pinoramaUrl: "PINORAMA_URL",
|
package/src/setup.test.ts
CHANGED
|
@@ -122,7 +122,7 @@ describe("SetupWizard", () => {
|
|
|
122
122
|
expect(settings.chatId).toBe("12345678");
|
|
123
123
|
expect(settings.model).toBe("opus");
|
|
124
124
|
expect(settings.workspace).toBe("/my/ws");
|
|
125
|
-
expect(settings.
|
|
125
|
+
expect(settings.timeZone).toBe("Europe/Prague");
|
|
126
126
|
expect(settings.openaiApiKey).toBe("sk-test");
|
|
127
127
|
expect(settings.logLevel).toBe("info");
|
|
128
128
|
});
|
|
@@ -151,7 +151,7 @@ describe("SetupWizard", () => {
|
|
|
151
151
|
expect(settings.chatId).toBe("99887766");
|
|
152
152
|
expect(settings.model).toBe("haiku");
|
|
153
153
|
expect(settings.workspace).toBe("/env/ws");
|
|
154
|
-
expect(settings.
|
|
154
|
+
expect(settings.timeZone).toBe("America/New_York");
|
|
155
155
|
expect(settings.openaiApiKey).toBe("sk-env");
|
|
156
156
|
});
|
|
157
157
|
|
package/src/setup.ts
CHANGED
|
@@ -88,8 +88,8 @@ export class SetupWizard {
|
|
|
88
88
|
this.#io.write("\nLocal timezone for the agent's clock and scheduled events.\n");
|
|
89
89
|
this.#io.write("Use an IANA timezone name (e.g. Europe/Prague, America/New_York, UTC).\n\n");
|
|
90
90
|
const detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
91
|
-
const defaultTimezone = this.#default("
|
|
92
|
-
const
|
|
91
|
+
const defaultTimezone = this.#default("timeZone", detectedTz || "UTC");
|
|
92
|
+
const timeZone = await this.#askValidated("timeZone", `Timezone [${defaultTimezone}]: `, defaultTimezone);
|
|
93
93
|
|
|
94
94
|
// OpenAI API key
|
|
95
95
|
this.#io.write("\nMacroclaw uses OpenAI's Whisper API to transcribe voice messages.\n");
|
|
@@ -106,7 +106,7 @@ export class SetupWizard {
|
|
|
106
106
|
chatId,
|
|
107
107
|
model,
|
|
108
108
|
workspace,
|
|
109
|
-
|
|
109
|
+
timeZone,
|
|
110
110
|
openaiApiKey,
|
|
111
111
|
...(logLevel && { logLevel }),
|
|
112
112
|
});
|
|
@@ -167,19 +167,11 @@ describe("install", () => {
|
|
|
167
167
|
expect(() => mgr.install()).toThrow("Settings not found. Run `macroclaw setup` first.");
|
|
168
168
|
});
|
|
169
169
|
|
|
170
|
-
it("runs global install
|
|
170
|
+
it("runs global install without resolving binary paths on systemd", () => {
|
|
171
171
|
const tmpHome = `/tmp/macroclaw-test-install-${Date.now()}`;
|
|
172
172
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
173
173
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
174
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
175
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
176
174
|
|
|
177
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
178
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
179
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
180
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
181
|
-
return "";
|
|
182
|
-
});
|
|
183
175
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
184
176
|
// Mock existsSync to handle linger check
|
|
185
177
|
mockExistsSync.mockImplementation((path: string) => {
|
|
@@ -191,41 +183,42 @@ describe("install", () => {
|
|
|
191
183
|
rmSync(tmpHome, { recursive: true });
|
|
192
184
|
|
|
193
185
|
expect(mockExecSync).toHaveBeenCalledWith("bun install -g macroclaw", expect.anything());
|
|
194
|
-
|
|
195
|
-
expect(mockExecSync).toHaveBeenCalledWith("which
|
|
196
|
-
expect(mockExecSync).toHaveBeenCalledWith("
|
|
186
|
+
// systemd no longer resolves paths — bash -lc handles PATH at runtime
|
|
187
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
|
|
188
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
|
|
189
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
|
|
197
190
|
});
|
|
198
191
|
|
|
199
|
-
it("installs launchd service with
|
|
192
|
+
it("installs launchd service with bash -lc and OAuth token", () => {
|
|
200
193
|
const tmpHome = `/tmp/macroclaw-test-launchd-${Date.now()}`;
|
|
201
194
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
202
195
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
203
196
|
const plistDir = join(tmpHome, "Library/LaunchAgents");
|
|
204
197
|
mkdirSync(plistDir, { recursive: true });
|
|
205
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
206
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
207
198
|
|
|
208
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
209
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
210
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
211
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
212
|
-
return "";
|
|
213
|
-
});
|
|
214
199
|
const mgr = createManager({ platform: "darwin", home: tmpHome });
|
|
215
200
|
mgr.install("sk-test-token");
|
|
216
201
|
|
|
217
202
|
const plistPath = join(plistDir, "com.macroclaw.plist");
|
|
218
203
|
expect(existsSync(plistPath)).toBe(true);
|
|
219
204
|
const writtenContent = readFileSync(plistPath, "utf-8");
|
|
220
|
-
|
|
221
|
-
expect(writtenContent).toContain(
|
|
222
|
-
expect(writtenContent).toContain("<string
|
|
205
|
+
// bash -lc pattern — no hardcoded binary paths
|
|
206
|
+
expect(writtenContent).toContain("<string>/bin/bash</string>");
|
|
207
|
+
expect(writtenContent).toContain("<string>-lc</string>");
|
|
208
|
+
expect(writtenContent).toContain("<string>exec bun macroclaw start</string>");
|
|
223
209
|
expect(writtenContent).toContain("<key>KeepAlive</key>");
|
|
224
210
|
expect(writtenContent).toContain(".macroclaw/logs/stdout.log");
|
|
225
|
-
|
|
211
|
+
// No PATH/HOME env vars — login shell provides them
|
|
212
|
+
expect(writtenContent).not.toContain("<key>PATH</key>");
|
|
213
|
+
expect(writtenContent).not.toContain("<key>HOME</key>");
|
|
214
|
+
// OAuth token is preserved
|
|
226
215
|
expect(writtenContent).toContain("<key>CLAUDE_CODE_OAUTH_TOKEN</key>");
|
|
227
216
|
expect(writtenContent).toContain("<string>sk-test-token</string>");
|
|
228
217
|
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("launchctl load"), expect.anything());
|
|
218
|
+
// No path resolution calls
|
|
219
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("which bun", expect.anything());
|
|
220
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("which claude", expect.anything());
|
|
221
|
+
expect(mockExecSync).not.toHaveBeenCalledWith("bun pm bin -g", expect.anything());
|
|
229
222
|
rmSync(tmpHome, { recursive: true });
|
|
230
223
|
});
|
|
231
224
|
|
|
@@ -234,19 +227,12 @@ describe("install", () => {
|
|
|
234
227
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
235
228
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
236
229
|
mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
|
|
237
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
238
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
239
230
|
|
|
240
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
241
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
242
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
243
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
244
|
-
return "";
|
|
245
|
-
});
|
|
246
231
|
const mgr = createManager({ platform: "darwin", home: tmpHome });
|
|
247
232
|
mgr.install();
|
|
248
233
|
const writtenContent = readFileSync(join(tmpHome, "Library/LaunchAgents/com.macroclaw.plist"), "utf-8");
|
|
249
234
|
expect(writtenContent).not.toContain("CLAUDE_CODE_OAUTH_TOKEN");
|
|
235
|
+
expect(writtenContent).not.toContain("<key>EnvironmentVariables</key>");
|
|
250
236
|
rmSync(tmpHome, { recursive: true });
|
|
251
237
|
});
|
|
252
238
|
|
|
@@ -255,15 +241,10 @@ describe("install", () => {
|
|
|
255
241
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
256
242
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
257
243
|
mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
|
|
258
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
259
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
260
244
|
|
|
261
245
|
const calls: string[] = [];
|
|
262
246
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
263
247
|
calls.push(cmd);
|
|
264
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
265
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
266
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
267
248
|
if (cmd.startsWith("launchctl list ")) return LAUNCHD_RUNNING;
|
|
268
249
|
return "";
|
|
269
250
|
});
|
|
@@ -281,13 +262,8 @@ describe("install", () => {
|
|
|
281
262
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
282
263
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
283
264
|
mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
|
|
284
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
285
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
286
265
|
|
|
287
266
|
mockExecSync.mockImplementation((cmd: string) => {
|
|
288
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
289
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
290
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
291
267
|
if (cmd.startsWith("launchctl list ")) return LAUNCHD_STOPPED;
|
|
292
268
|
return "";
|
|
293
269
|
});
|
|
@@ -297,19 +273,11 @@ describe("install", () => {
|
|
|
297
273
|
rmSync(tmpHome, { recursive: true });
|
|
298
274
|
});
|
|
299
275
|
|
|
300
|
-
it("installs systemd user service and
|
|
276
|
+
it("installs systemd user service with bash -lc and no hardcoded paths", () => {
|
|
301
277
|
const tmpHome = `/tmp/macroclaw-test-systemd-${Date.now()}`;
|
|
302
278
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
303
279
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
304
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
305
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
306
280
|
|
|
307
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
308
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
309
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
310
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
311
|
-
return "";
|
|
312
|
-
});
|
|
313
281
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
314
282
|
// Mock existsSync: linger file does not exist (triggers sudo loginctl)
|
|
315
283
|
mockExistsSync.mockImplementation((path: string) => {
|
|
@@ -326,8 +294,11 @@ describe("install", () => {
|
|
|
326
294
|
expect(unitContent).toContain("WantedBy=default.target");
|
|
327
295
|
expect(unitContent).not.toContain("User=");
|
|
328
296
|
expect(unitContent).not.toContain("Group=");
|
|
329
|
-
|
|
330
|
-
expect(unitContent).toContain(
|
|
297
|
+
// bash -lc sources login profile for PATH — no hardcoded Environment lines
|
|
298
|
+
expect(unitContent).not.toContain("Environment=HOME=");
|
|
299
|
+
expect(unitContent).not.toContain("Environment=PATH=");
|
|
300
|
+
expect(unitContent).toContain("WorkingDirectory=%h");
|
|
301
|
+
expect(unitContent).toContain("ExecStart=/bin/bash -lc 'exec bun macroclaw start'");
|
|
331
302
|
|
|
332
303
|
// Lingering enabled via sudo
|
|
333
304
|
expect(mockExecSync).toHaveBeenCalledWith("sudo loginctl enable-linger testuser", expect.anything());
|
|
@@ -346,15 +317,7 @@ describe("install", () => {
|
|
|
346
317
|
const tmpHome = `/tmp/macroclaw-test-linger-${Date.now()}`;
|
|
347
318
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
348
319
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
349
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
350
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
351
320
|
|
|
352
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
353
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
354
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
355
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
356
|
-
return "";
|
|
357
|
-
});
|
|
358
321
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
359
322
|
// Linger already enabled
|
|
360
323
|
mockExistsSync.mockImplementation((path: string) => {
|
|
@@ -372,15 +335,7 @@ describe("install", () => {
|
|
|
372
335
|
const tmpHome = `/tmp/macroclaw-test-nosudo-${Date.now()}`;
|
|
373
336
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
374
337
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
375
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
376
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
377
338
|
|
|
378
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
379
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
380
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
381
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
382
|
-
return "";
|
|
383
|
-
});
|
|
384
339
|
mockUserInfo.mockImplementation(() => ({ username: "testuser", homedir: tmpHome, uid: 1000, gid: 1000, shell: "/bin/bash" }));
|
|
385
340
|
mockExistsSync.mockImplementation((path: string) => {
|
|
386
341
|
if (path === "/var/lib/systemd/linger/testuser") return true;
|
|
@@ -393,52 +348,12 @@ describe("install", () => {
|
|
|
393
348
|
rmSync(tmpHome, { recursive: true });
|
|
394
349
|
});
|
|
395
350
|
|
|
396
|
-
it("throws when bun path cannot be resolved", () => {
|
|
397
|
-
const tmpHome = `/tmp/macroclaw-test-nobun-${Date.now()}`;
|
|
398
|
-
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
399
|
-
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
400
|
-
|
|
401
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
402
|
-
if (cmd === "which bun") throw new Error("not found");
|
|
403
|
-
return "";
|
|
404
|
-
});
|
|
405
|
-
const mgr = createManager({ home: tmpHome });
|
|
406
|
-
expect(() => mgr.install()).toThrow("Could not resolve bun path. Is it installed?");
|
|
407
|
-
rmSync(tmpHome, { recursive: true });
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
it("throws when macroclaw not found in global bin", () => {
|
|
411
|
-
const tmpHome = `/tmp/macroclaw-test-nomc-${Date.now()}`;
|
|
412
|
-
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
413
|
-
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
414
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
415
|
-
// Note: NOT creating macroclaw binary
|
|
416
|
-
|
|
417
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
418
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
419
|
-
if (cmd === "which claude") return `${tmpHome}/.local/bin/claude\n`;
|
|
420
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
421
|
-
return "";
|
|
422
|
-
});
|
|
423
|
-
const mgr = createManager({ home: tmpHome });
|
|
424
|
-
expect(() => mgr.install()).toThrow(`Could not find macroclaw in ${tmpHome}/.bun/bin`);
|
|
425
|
-
rmSync(tmpHome, { recursive: true });
|
|
426
|
-
});
|
|
427
|
-
|
|
428
351
|
it("macOS install does not use sudo", () => {
|
|
429
352
|
const tmpHome = `/tmp/macroclaw-test-macos-${Date.now()}`;
|
|
430
353
|
mkdirSync(join(tmpHome, ".macroclaw"), { recursive: true });
|
|
431
354
|
writeFileSync(join(tmpHome, ".macroclaw/settings.json"), "{}");
|
|
432
355
|
mkdirSync(join(tmpHome, "Library/LaunchAgents"), { recursive: true });
|
|
433
|
-
mkdirSync(join(tmpHome, ".bun/bin"), { recursive: true });
|
|
434
|
-
writeFileSync(join(tmpHome, ".bun/bin/macroclaw"), "");
|
|
435
356
|
|
|
436
|
-
mockExecSync.mockImplementation((cmd: string) => {
|
|
437
|
-
if (cmd === "which bun") return `${tmpHome}/.bun/bin/bun\n`;
|
|
438
|
-
if (cmd === "which claude") return `${tmpHome}/.bun/bin/claude\n`;
|
|
439
|
-
if (cmd === "bun pm bin -g") return `${tmpHome}/.bun/bin\n`;
|
|
440
|
-
return "";
|
|
441
|
-
});
|
|
442
357
|
const mgr = createManager({ platform: "darwin", home: tmpHome });
|
|
443
358
|
mgr.install();
|
|
444
359
|
for (const call of mockExecSync.mock.calls) {
|
package/src/system-service.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { userInfo as osUserInfo } from "node:os";
|
|
4
|
-
import { dirname,
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
5
|
import { createLogger } from "./logger";
|
|
6
6
|
|
|
7
7
|
const log = createLogger("service");
|
|
@@ -85,11 +85,6 @@ export class SystemServiceManager {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
this.#exec("bun install -g macroclaw");
|
|
88
|
-
const bunPath = this.#resolvePath("bun");
|
|
89
|
-
const claudePath = this.#resolvePath("claude");
|
|
90
|
-
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
91
|
-
|
|
92
|
-
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
93
88
|
|
|
94
89
|
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
95
90
|
mkdirSync(logDir, { recursive: true });
|
|
@@ -97,7 +92,7 @@ export class SystemServiceManager {
|
|
|
97
92
|
this.#exec(`launchctl unload ${this.serviceFilePath}`);
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(
|
|
95
|
+
writeFileSync(this.serviceFilePath, this.#generateLaunchdPlist(oauthToken));
|
|
101
96
|
log.debug({ filePath: this.serviceFilePath }, "Wrote launchd plist");
|
|
102
97
|
this.#exec(`launchctl load ${this.serviceFilePath}`);
|
|
103
98
|
}
|
|
@@ -113,11 +108,6 @@ export class SystemServiceManager {
|
|
|
113
108
|
}
|
|
114
109
|
|
|
115
110
|
this.#exec("bun install -g macroclaw");
|
|
116
|
-
const bunPath = this.#resolvePath("bun");
|
|
117
|
-
const claudePath = this.#resolvePath("claude");
|
|
118
|
-
const macroclawPath = this.#resolveGlobalBinPath("macroclaw");
|
|
119
|
-
|
|
120
|
-
const pathDirs = [...new Set([dirname(bunPath), dirname(claudePath), dirname(macroclawPath)])];
|
|
121
111
|
|
|
122
112
|
// Enable lingering so user services run without an active login session
|
|
123
113
|
const username = osUserInfo().username;
|
|
@@ -125,7 +115,7 @@ export class SystemServiceManager {
|
|
|
125
115
|
this.#sudo(`loginctl enable-linger ${username}`);
|
|
126
116
|
}
|
|
127
117
|
|
|
128
|
-
const unitContent = this.#generateSystemdUnit(
|
|
118
|
+
const unitContent = this.#generateSystemdUnit();
|
|
129
119
|
mkdirSync(dirname(this.serviceFilePath), { recursive: true });
|
|
130
120
|
writeFileSync(this.serviceFilePath, unitContent);
|
|
131
121
|
log.debug({ filePath: this.serviceFilePath }, "Wrote systemd unit");
|
|
@@ -252,23 +242,6 @@ export class SystemServiceManager {
|
|
|
252
242
|
return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).toString();
|
|
253
243
|
}
|
|
254
244
|
|
|
255
|
-
#resolvePath(binary: string): string {
|
|
256
|
-
try {
|
|
257
|
-
return this.#exec(`which ${binary}`).trim();
|
|
258
|
-
} catch {
|
|
259
|
-
throw new Error(`Could not resolve ${binary} path. Is it installed?`);
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
#resolveGlobalBinPath(binary: string): string {
|
|
264
|
-
const binDir = this.#exec("bun pm bin -g").trim();
|
|
265
|
-
const binPath = join(binDir, binary);
|
|
266
|
-
if (!existsSync(binPath)) {
|
|
267
|
-
throw new Error(`Could not find ${binary} in ${binDir}. Is it installed?`);
|
|
268
|
-
}
|
|
269
|
-
return binPath;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
245
|
|
|
273
246
|
#getInstalledVersion(): string {
|
|
274
247
|
try {
|
|
@@ -298,9 +271,11 @@ export class SystemServiceManager {
|
|
|
298
271
|
this.#exec(`sudo ${cmd}`);
|
|
299
272
|
}
|
|
300
273
|
|
|
301
|
-
#generateLaunchdPlist(
|
|
274
|
+
#generateLaunchdPlist(oauthToken?: string): string {
|
|
302
275
|
const logDir = resolve(this.#home, ".macroclaw/logs");
|
|
303
|
-
const
|
|
276
|
+
const tokenEnvBlock = oauthToken
|
|
277
|
+
? `\n\t<key>EnvironmentVariables</key>\n\t<dict>\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>\n\t</dict>`
|
|
278
|
+
: "";
|
|
304
279
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
305
280
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
306
281
|
<plist version="1.0">
|
|
@@ -309,39 +284,30 @@ export class SystemServiceManager {
|
|
|
309
284
|
<string>com.macroclaw</string>
|
|
310
285
|
<key>ProgramArguments</key>
|
|
311
286
|
<array>
|
|
312
|
-
<string
|
|
313
|
-
<string
|
|
314
|
-
<string>start</string>
|
|
287
|
+
<string>/bin/bash</string>
|
|
288
|
+
<string>-lc</string>
|
|
289
|
+
<string>exec bun macroclaw start</string>
|
|
315
290
|
</array>
|
|
316
291
|
<key>KeepAlive</key>
|
|
317
292
|
<true/>
|
|
318
293
|
<key>StandardOutPath</key>
|
|
319
294
|
<string>${logDir}/stdout.log</string>
|
|
320
295
|
<key>StandardErrorPath</key>
|
|
321
|
-
<string>${logDir}/stderr.log</string
|
|
322
|
-
<key>EnvironmentVariables</key>
|
|
323
|
-
<dict>
|
|
324
|
-
<key>HOME</key>
|
|
325
|
-
<string>${this.#home}</string>
|
|
326
|
-
<key>PATH</key>
|
|
327
|
-
<string>${pathDirs.join(":")}</string>${tokenEnv}
|
|
328
|
-
</dict>
|
|
296
|
+
<string>${logDir}/stderr.log</string>${tokenEnvBlock}
|
|
329
297
|
</dict>
|
|
330
298
|
</plist>
|
|
331
299
|
`;
|
|
332
300
|
}
|
|
333
301
|
|
|
334
|
-
#generateSystemdUnit(
|
|
302
|
+
#generateSystemdUnit(): string {
|
|
335
303
|
return `[Unit]
|
|
336
304
|
Description=Macroclaw - Telegram-to-Claude-Code bridge
|
|
337
305
|
After=network.target
|
|
338
306
|
|
|
339
307
|
[Service]
|
|
340
308
|
Type=simple
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
WorkingDirectory=${this.#home}
|
|
344
|
-
ExecStart=${bunPath} ${macroclawPath} start
|
|
309
|
+
WorkingDirectory=%h
|
|
310
|
+
ExecStart=/bin/bash -lc 'exec bun macroclaw start'
|
|
345
311
|
Restart=on-failure
|
|
346
312
|
RestartSec=5
|
|
347
313
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: settings
|
|
3
|
-
description: "Read or change macroclaw settings (model,
|
|
3
|
+
description: "Read or change macroclaw settings (model, timeZone). Use when the user asks about current settings, wants to switch the Claude model, change the timezone, or asks what model/timezone is configured."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
Read or change macroclaw settings. Only `model` and `
|
|
6
|
+
Read or change macroclaw settings. Only `model` and `timeZone` can be changed through this skill.
|
|
7
7
|
|
|
8
8
|
## Settings file
|
|
9
9
|
|
|
@@ -11,7 +11,7 @@ Location: `~/.macroclaw/settings.json`
|
|
|
11
11
|
|
|
12
12
|
## Reading settings
|
|
13
13
|
|
|
14
|
-
When the user asks about current settings ("what model am I on?", "what's the
|
|
14
|
+
When the user asks about current settings ("what model am I on?", "what's the time zone?"):
|
|
15
15
|
|
|
16
16
|
1. Read `~/.macroclaw/settings.json`
|
|
17
17
|
2. Report the requested value
|
|
@@ -20,7 +20,7 @@ When the user asks about current settings ("what model am I on?", "what's the ti
|
|
|
20
20
|
|
|
21
21
|
Allowed changes:
|
|
22
22
|
- **model**: `haiku`, `sonnet`, or `opus`
|
|
23
|
-
- **
|
|
23
|
+
- **timeZone**: any valid IANA timezone (e.g. `Europe/Prague`, `America/New_York`, `UTC`)
|
|
24
24
|
|
|
25
25
|
All other settings (botToken, chatId, workspace, etc.) cannot be changed through this skill — tell the user to run `macroclaw setup` instead.
|
|
26
26
|
|