macroclaw 0.3.0 → 0.5.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/src/cli.ts ADDED
@@ -0,0 +1,187 @@
1
+ import {execSync} from "node:child_process";
2
+ import {existsSync, readFileSync} from "node:fs";
3
+ import {join, resolve} from "node:path";
4
+ import {createInterface} from "node:readline";
5
+ import {defineCommand} from "citty";
6
+ import pkg from "../package.json" with { type: "json" };
7
+ import {initLogger} from "./logger";
8
+ import {ServiceManager, type SystemService} from "./service";
9
+ import {loadSessions} from "./sessions";
10
+ import {loadSettings, saveSettings} from "./settings";
11
+ import {runSetupWizard} from "./setup";
12
+
13
+ export interface SetupDeps {
14
+ initLogger: () => Promise<void>;
15
+ saveSettings: (settings: unknown, dir: string) => void;
16
+ loadRawSettings: (dir: string) => Record<string, unknown> | null;
17
+ runSetupWizard: (io: { ask: (q: string) => Promise<string>; write: (m: string) => void; close?: () => void }, opts?: { defaults?: Record<string, unknown>; onSettingsReady?: (settings: unknown) => void }) => Promise<unknown>;
18
+ createReadlineInterface: () => { question: (q: string, cb: (a: string) => void) => void; close: () => void };
19
+ resolveDir: () => string;
20
+ }
21
+
22
+ export function loadRawSettings(dir: string): Record<string, unknown> | null {
23
+ const path = join(dir, "settings.json");
24
+ if (!existsSync(path)) return null;
25
+ try {
26
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
27
+ return typeof raw === "object" && raw !== null ? raw : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function defaultSetupDeps(): SetupDeps {
34
+ return {
35
+ initLogger,
36
+ saveSettings: saveSettings as (settings: unknown, dir: string) => void,
37
+ loadRawSettings,
38
+ runSetupWizard: runSetupWizard as SetupDeps["runSetupWizard"],
39
+ createReadlineInterface: () => createInterface({ input: process.stdin, output: process.stdout }),
40
+ resolveDir: () => resolve(process.env.HOME || "~", ".macroclaw"),
41
+ };
42
+ }
43
+
44
+ function defaultSystemService(): SystemService {
45
+ return new ServiceManager();
46
+ }
47
+
48
+ export class Cli {
49
+ readonly #setupDeps: SetupDeps;
50
+ readonly #systemService: SystemService;
51
+
52
+ constructor(setupDeps?: Partial<SetupDeps>, systemService?: SystemService) {
53
+ this.#setupDeps = { ...defaultSetupDeps(), ...setupDeps };
54
+ this.#systemService = systemService ?? defaultSystemService();
55
+ }
56
+
57
+ async setup(): Promise<void> {
58
+ await this.#setupDeps.initLogger();
59
+ const rl = this.#setupDeps.createReadlineInterface();
60
+ const io = {
61
+ ask: (question: string): Promise<string> =>
62
+ new Promise((res) => rl.question(question, (answer: string) => res(answer.trim()))),
63
+ write: (msg: string) => process.stdout.write(msg),
64
+ close: () => rl.close(),
65
+ };
66
+ const dir = this.#setupDeps.resolveDir();
67
+ const defaults = this.#setupDeps.loadRawSettings(dir) ?? undefined;
68
+ const settings = await this.#setupDeps.runSetupWizard(io, {
69
+ defaults,
70
+ onSettingsReady: (s) => this.#setupDeps.saveSettings(s, dir),
71
+ });
72
+ this.#setupDeps.saveSettings(settings, dir);
73
+ }
74
+
75
+ claude(exec: (cmd: string, opts: object) => void = (cmd, opts) => execSync(cmd, opts)): void {
76
+ const dir = this.#setupDeps.resolveDir();
77
+ const settings = loadSettings(dir);
78
+ if (!settings) throw new Error("Settings not found. Run `macroclaw setup` first.");
79
+ const sessions = loadSessions(dir);
80
+ const args = ["claude"];
81
+ if (sessions.mainSessionId) args.push("--resume", sessions.mainSessionId);
82
+ args.push("--model", settings.model);
83
+ exec(args.join(" "), { cwd: settings.workspace, stdio: "inherit", env: { ...process.env, CLAUDECODE: "" } });
84
+ }
85
+
86
+ service(action: string, token?: string): void {
87
+ switch (action) {
88
+ case "install": {
89
+ const logCmd = this.#systemService.install(token);
90
+ console.log(`Service installed and started. Check logs:\n ${logCmd}`);
91
+ break;
92
+ }
93
+ case "uninstall":
94
+ this.#systemService.uninstall();
95
+ console.log("Service uninstalled.");
96
+ break;
97
+ case "start": {
98
+ const logCmd = this.#systemService.start();
99
+ console.log(`Service started. Check logs:\n ${logCmd}`);
100
+ break;
101
+ }
102
+ case "stop":
103
+ this.#systemService.stop();
104
+ console.log("Service stopped.");
105
+ break;
106
+ case "update": {
107
+ const logCmd = this.#systemService.update();
108
+ console.log(`Service updated. Check logs:\n ${logCmd}`);
109
+ break;
110
+ }
111
+ default:
112
+ throw new Error(`Unknown service action: ${action}`);
113
+ }
114
+ }
115
+ }
116
+
117
+ export function handleError(err: unknown): never {
118
+ console.error(err instanceof Error ? err.message : String(err));
119
+ process.exit(1);
120
+ }
121
+
122
+ async function runStart(): Promise<void> {
123
+ const { start } = await import("./index");
124
+ await start();
125
+ }
126
+
127
+ const defaultCli = new Cli();
128
+
129
+ const startCommand = defineCommand({
130
+ meta: { name: "start", description: "Start the macroclaw bridge" },
131
+ run: () => runStart().catch(handleError),
132
+ });
133
+
134
+ const setupCommand = defineCommand({
135
+ meta: { name: "setup", description: "Run the interactive setup wizard" },
136
+ run: () => defaultCli.setup().catch(handleError),
137
+ });
138
+
139
+ const claudeCommand = defineCommand({
140
+ meta: { name: "claude", description: "Open Claude Code CLI in the main session" },
141
+ run: () => { try { defaultCli.claude(); } catch (err) { handleError(err); } },
142
+ });
143
+
144
+ const serviceInstallCommand = defineCommand({
145
+ meta: { name: "install", description: "Install and start macroclaw as a system service" },
146
+ args: {
147
+ token: { type: "string", description: "Claude OAuth token from `claude setup-token` (required on macOS)" },
148
+ },
149
+ run: ({ args }) => { try { defaultCli.service("install", args.token); } catch (err) { handleError(err); } },
150
+ });
151
+
152
+ const serviceUninstallCommand = defineCommand({
153
+ meta: { name: "uninstall", description: "Stop and remove the system service" },
154
+ run: () => { try { defaultCli.service("uninstall"); } catch (err) { handleError(err); } },
155
+ });
156
+
157
+ const serviceStartCommand = defineCommand({
158
+ meta: { name: "start", description: "Start the system service" },
159
+ run: () => { try { defaultCli.service("start"); } catch (err) { handleError(err); } },
160
+ });
161
+
162
+ const serviceStopCommand = defineCommand({
163
+ meta: { name: "stop", description: "Stop the system service" },
164
+ run: () => { try { defaultCli.service("stop"); } catch (err) { handleError(err); } },
165
+ });
166
+
167
+ const serviceUpdateCommand = defineCommand({
168
+ meta: { name: "update", description: "Reinstall latest version and restart the service" },
169
+ run: () => { try { defaultCli.service("update"); } catch (err) { handleError(err); } },
170
+ });
171
+
172
+ const serviceCommand = defineCommand({
173
+ meta: { name: "service", description: "Manage macroclaw system service" },
174
+ subCommands: {
175
+ install: serviceInstallCommand,
176
+ uninstall: serviceUninstallCommand,
177
+ start: serviceStartCommand,
178
+ stop: serviceStopCommand,
179
+ update: serviceUpdateCommand,
180
+ },
181
+ });
182
+
183
+ export const main = defineCommand({
184
+ meta: { name: pkg.name, description: pkg.description, version: pkg.version },
185
+ subCommands: { start: startCommand, setup: setupCommand, claude: claudeCommand, service: serviceCommand },
186
+ });
187
+
package/src/index.ts CHANGED
@@ -1,58 +1,48 @@
1
1
  import { cpSync, existsSync, readdirSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { createInterface } from "node:readline";
4
3
  import { App, type AppConfig } from "./app";
5
4
  import { createLogger, initLogger } from "./logger";
6
- import { applyEnvOverrides, loadSettings, printSettings, saveSettings } from "./settings";
7
- import { runSetupWizard } from "./setup";
5
+ import { applyEnvOverrides, loadSettings, printSettings } from "./settings";
8
6
 
9
- await initLogger();
10
- const log = createLogger("index");
7
+ export async function start(): Promise<void> {
8
+ const log = createLogger("index");
11
9
 
12
- const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
10
+ const defaultDir = resolve(process.env.HOME || "~", ".macroclaw");
13
11
 
14
- let settings = loadSettings(defaultDir);
12
+ const settings = loadSettings(defaultDir);
15
13
 
16
- if (!settings) {
17
- log.info("No settings.json found, starting setup wizard");
18
- const rl = createInterface({ input: process.stdin, output: process.stdout });
19
- const io = {
20
- ask: (question: string): Promise<string> =>
21
- new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim()))),
22
- write: (msg: string) => process.stdout.write(msg),
23
- };
24
- settings = await runSetupWizard(io);
25
- rl.close();
26
- saveSettings(settings, defaultDir);
27
- log.info("Settings saved");
28
- }
14
+ if (!settings) {
15
+ log.error("No settings found. Run `macroclaw setup` first.");
16
+ process.exit(1);
17
+ }
29
18
 
30
- const { settings: resolved, overrides } = applyEnvOverrides(settings);
31
- settings = resolved;
19
+ const { settings: resolved, overrides } = applyEnvOverrides(settings);
32
20
 
33
- printSettings(settings, overrides);
21
+ await initLogger({ level: resolved.logLevel, pinoramaUrl: resolved.pinoramaUrl });
22
+ printSettings(resolved, overrides);
34
23
 
35
- const workspace = resolve(settings.workspace.replace(/^~/, process.env.HOME || "~"));
24
+ const workspace = resolve(resolved.workspace.replace(/^~/, process.env.HOME || "~"));
36
25
 
37
- function initWorkspace(workspace: string) {
38
- const templateDir = join(dirname(import.meta.dir), "workspace-template");
39
- const exists = existsSync(workspace);
40
- const empty = exists && readdirSync(workspace).length === 0;
26
+ function initWorkspace(workspace: string) {
27
+ const templateDir = join(dirname(import.meta.dir), "workspace-template");
28
+ const exists = existsSync(workspace);
29
+ const empty = exists && readdirSync(workspace).length === 0;
41
30
 
42
- if (!exists || empty) {
43
- log.info({ workspace }, "Initializing workspace from template");
44
- cpSync(templateDir, workspace, { recursive: true });
45
- log.info("Workspace initialized");
31
+ if (!exists || empty) {
32
+ log.info({ workspace }, "Initializing workspace from template");
33
+ cpSync(templateDir, workspace, { recursive: true });
34
+ log.info("Workspace initialized");
35
+ }
46
36
  }
47
- }
48
37
 
49
- initWorkspace(workspace);
38
+ initWorkspace(workspace);
50
39
 
51
- const config: AppConfig = {
52
- botToken: settings.botToken,
53
- authorizedChatId: settings.chatId,
54
- workspace,
55
- model: settings.model,
56
- };
40
+ const config: AppConfig = {
41
+ botToken: resolved.botToken,
42
+ authorizedChatId: resolved.chatId,
43
+ workspace,
44
+ model: resolved.model,
45
+ };
57
46
 
58
- new App(config).start();
47
+ new App(config).start();
48
+ }
@@ -17,17 +17,28 @@ describe("createLogger", () => {
17
17
  });
18
18
 
19
19
  describe("initLogger", () => {
20
- it("adds pinorama transport when PINORAMA_URL is set", async () => {
21
- process.env.PINORAMA_URL = "http://localhost:6200/pinorama";
20
+ it("does nothing when called without opts", async () => {
21
+ mockPinoramaTransport.mockClear();
22
22
  await initLogger();
23
- expect(mockPinoramaTransport).toHaveBeenCalledWith({ url: "http://localhost:6200/pinorama" });
24
- delete process.env.PINORAMA_URL;
23
+ expect(mockPinoramaTransport).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it("sets log level from opts", async () => {
27
+ const log = createLogger("opts-level");
28
+ await initLogger({ level: "warn" });
29
+ expect(log.level).toBe("warn");
30
+ await initLogger({ level: "info" }); // restore
25
31
  });
26
32
 
27
- it("does nothing when PINORAMA_URL is not set", async () => {
28
- delete process.env.PINORAMA_URL;
33
+ it("adds pinorama transport from opts", async () => {
29
34
  mockPinoramaTransport.mockClear();
30
- await initLogger();
35
+ await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
36
+ expect(mockPinoramaTransport).toHaveBeenCalledWith({ url: "http://example.com/pinorama" });
37
+ });
38
+
39
+ it("does not add duplicate pinorama transport on second call", async () => {
40
+ mockPinoramaTransport.mockClear();
41
+ await initLogger({ pinoramaUrl: "http://example.com/pinorama" });
31
42
  expect(mockPinoramaTransport).not.toHaveBeenCalled();
32
43
  });
33
44
  });
package/src/logger.ts CHANGED
@@ -6,20 +6,30 @@ const prettyStream = pretty({
6
6
  messageFormat: "[{module}] {msg}",
7
7
  });
8
8
 
9
- const level = (process.env.LOG_LEVEL || "info") as pino.Level;
9
+ const defaultLevel = (process.env.LOG_LEVEL || "info") as pino.Level;
10
10
 
11
- const streams: pino.StreamEntry[] = [{ level, stream: prettyStream }];
11
+ // Streams accept all levels; logger.level is the sole gate
12
+ const streams: pino.StreamEntry[] = [{ level: "trace", stream: prettyStream }];
12
13
 
13
14
  const logger = pino(
14
- { level: process.env.LOG_LEVEL || "info" },
15
+ { level: defaultLevel },
15
16
  pino.multistream(streams),
16
17
  );
17
18
 
18
- export async function initLogger(): Promise<void> {
19
- const pinoramaUrl = process.env.PINORAMA_URL;
20
- if (pinoramaUrl) {
19
+ export interface LoggerOptions {
20
+ level?: pino.Level;
21
+ pinoramaUrl?: string;
22
+ }
23
+
24
+ let pinoramaAdded = false;
25
+
26
+ export async function initLogger(opts?: LoggerOptions): Promise<void> {
27
+ if (opts?.level) logger.level = opts.level;
28
+
29
+ if (opts?.pinoramaUrl && !pinoramaAdded) {
21
30
  const { default: pinoramaTransport } = await import("pinorama-transport");
22
- streams.push({ level, stream: pinoramaTransport({ url: pinoramaUrl }) });
31
+ streams.push({ level: "trace", stream: pinoramaTransport({ url: opts.pinoramaUrl }) });
32
+ pinoramaAdded = true;
23
33
  }
24
34
  }
25
35