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 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
- | `timezone` | `TIMEZONE` | `UTC` | No |
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
- **`timezone`** sets the agent's local timezone (IANA format, e.g. `Europe/Prague`, `America/New_York`). Used for the agent's clock display and scheduled event timing.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "macroclaw",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Telegram-to-Claude-Code bridge",
5
5
  "license": "MIT",
6
6
  "type": "module",
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
- timezone: "UTC",
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
- timezone: string;
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
- timezone: config.timezone,
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
- timezone: this.#config.timezone,
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
- timezone: resolved.timezone,
43
+ timeZone: resolved.timeZone,
44
44
  stt: resolved.openaiApiKey ? new SpeechToText(resolved.openaiApiKey) : undefined,
45
45
  };
46
46
 
@@ -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
- timezone: "UTC",
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
- timezone: "UTC",
1159
+ timeZone: "UTC",
1160
1160
  settingsDir: tmpSettingsDir,
1161
1161
  onResponse: failingOnResponse,
1162
1162
  claude,
@@ -92,7 +92,7 @@ interface SessionInfo {
92
92
  export interface OrchestratorConfig {
93
93
  model: string;
94
94
  workspace: string;
95
- timezone: string;
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.timezone);
123
- const envVars: Record<string, string> = { TZ: config.timezone };
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 timezone but not a fixed date", () => {
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");
@@ -82,18 +82,18 @@ interface BuildXmlFields {
82
82
  }
83
83
 
84
84
  export class PromptBuilder {
85
- readonly #timezone: string;
85
+ readonly #timeZone: string;
86
86
 
87
- constructor(timezone: string) {
88
- this.#timezone = timezone;
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.#timezone}. TZ env var is set — \`date\` and other CLI tools return local time.`;
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.#timezone).toFormat("yyyy-MM-dd'T'HH:mm");
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 {
@@ -73,7 +73,7 @@ describe("Scheduler — cron jobs", () => {
73
73
  });
74
74
 
75
75
  const onJob = makeOnJob();
76
- const s = new Scheduler(TEST_DIR, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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 timezone", () => {
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, { timezone: "Europe/Prague", onJob });
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 timezone)", () => {
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, { timezone: "Asia/Tokyo", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob });
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, { timezone: "UTC", onJob: onJob2 });
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, { timezone: "UTC", onJob });
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
- timezone: string;
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
- #timezone: string;
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.#timezone = config.timezone;
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.#timezone });
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 timezone. */
136
- static #parseFireAt(fireAt: string, timezone: string): Date {
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 timezone
143
- return DateTime.fromISO(fireAt, { zone: timezone }).toJSDate();
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.#timezone);
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";
@@ -10,7 +10,7 @@ const validSettings: Settings = {
10
10
  chatId: "12345678",
11
11
  model: "sonnet",
12
12
  workspace: "~/.macroclaw-workspace",
13
- timezone: "UTC",
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
- timezone: "UTC",
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
- timezone: " Europe/Prague ",
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
- timezone: "Europe/Prague",
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
- timezone: "UTC",
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 timezone)", () => {
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
- timezone: "Europe/Prgaaue",
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.timezone).toBe("Europe/Prague");
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
- timezone: z.string().trim().refine((tz) => IANAZone.isValidZone(tz), "Must be a valid IANA timezone").default("UTC"),
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
- timezone: "TIMEZONE",
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.timezone).toBe("Europe/Prague");
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.timezone).toBe("America/New_York");
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("timezone", detectedTz || "UTC");
92
- const timezone = await this.#askValidated("timezone", `Timezone [${defaultTimezone}]: `, defaultTimezone);
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
- timezone,
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 and resolves bun, claude and macroclaw paths", () => {
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
- expect(mockExecSync).toHaveBeenCalledWith("which bun", expect.anything());
195
- expect(mockExecSync).toHaveBeenCalledWith("which claude", expect.anything());
196
- expect(mockExecSync).toHaveBeenCalledWith("bun pm bin -g", expect.anything());
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 PATH and OAuth token", () => {
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
- expect(writtenContent).toContain(`<string>${tmpHome}/.bun/bin/bun</string>`);
221
- expect(writtenContent).toContain(`<string>${tmpHome}/.bun/bin/macroclaw</string>`);
222
- expect(writtenContent).toContain("<string>start</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
- expect(writtenContent).toContain("<key>PATH</key>");
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 writes unit file directly", () => {
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
- expect(unitContent).toContain(`Environment=HOME=${tmpHome}`);
330
- expect(unitContent).toContain(`ExecStart=${tmpHome}/.bun/bin/bun ${tmpHome}/.bun/bin/macroclaw start`);
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) {
@@ -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, join, resolve } from "node:path";
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(bunPath, macroclawPath, pathDirs, oauthToken));
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(bunPath, macroclawPath, pathDirs);
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(bunPath: string, macroclawPath: string, pathDirs: string[], oauthToken?: string): string {
274
+ #generateLaunchdPlist(oauthToken?: string): string {
302
275
  const logDir = resolve(this.#home, ".macroclaw/logs");
303
- const tokenEnv = oauthToken ? `\n\t\t<key>CLAUDE_CODE_OAUTH_TOKEN</key>\n\t\t<string>${oauthToken}</string>` : "";
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>${bunPath}</string>
313
- <string>${macroclawPath}</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(bunPath: string, macroclawPath: string, pathDirs: string[]): string {
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
- Environment=HOME=${this.#home}
342
- Environment=PATH=${pathDirs.join(":")}
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, 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."
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 `timezone` can be changed through this skill.
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 timezone?"):
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
- - **timezone**: any valid IANA timezone (e.g. `Europe/Prague`, `America/New_York`, `UTC`)
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